├── .travis.yml ├── rest ├── models │ ├── model_columns_response.go │ ├── model_tables_response.go │ ├── model_row_add.go │ ├── model_rows_response.go │ ├── model_rows.go │ ├── model_column_update.go │ ├── model_column.go │ ├── model_error.go │ ├── model_table.go │ ├── model_rows_update.go │ ├── model_query.go │ ├── model_table_add.go │ └── model_column_definition.go ├── endpoint │ └── v1 │ │ ├── internal_error.go │ │ ├── response.go │ │ └── routes.go └── route_generator.go ├── main.go ├── data.json ├── errors ├── conflict_error.go ├── internal_error.go ├── not_found_error.go └── error_utils.go ├── auth ├── auth_test.go └── auth.go ├── .gitignore ├── Dockerfile ├── appveyor.yml ├── ci └── ubuntu_tools.sh ├── db ├── keyspace.go ├── host_selection_integration_test.go ├── host_selection.go ├── table.go ├── type_mapping.go ├── mocks.go ├── session.go ├── query_generators.go ├── db.go ├── type_mapping_test.go └── query_generators_test.go ├── config ├── schema_operations_test.go ├── schema_operations.go ├── mocks.go ├── config.go ├── naming_test.go └── naming.go ├── internal └── testutil │ ├── schemas │ ├── datatypes │ │ ├── schema.cql │ │ └── datatypes.go │ ├── quirky │ │ ├── schema.cql │ │ └── quirky.go │ ├── killrvideo │ │ ├── killrvideo.go │ │ └── schema.cql │ └── util.go │ ├── simulacron.go │ ├── rest │ └── rest.go │ └── testutil.go ├── go.mod ├── graphql ├── updater_test.go ├── operators_input_types.go ├── playground.go ├── updater.go ├── scalars.go ├── routes.go ├── resolvers.go └── schema.go ├── types ├── types.go └── conversions.go ├── log └── logger.go ├── store.cql ├── endpoint └── endpoint.go ├── README.md └── LICENSE.txt /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - "1.14" 5 | 6 | script: 7 | - docker build -t test . 8 | -------------------------------------------------------------------------------- /rest/models/model_columns_response.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type ColumnsResponse struct { 4 | Success bool `json:"success,omitempty"` 5 | } 6 | -------------------------------------------------------------------------------- /rest/models/model_tables_response.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type TablesResponse struct { 4 | Success bool `json:"success,omitempty"` 5 | } 6 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/datastax/cassandra-data-apis/cmd" 5 | ) 6 | 7 | func main() { 8 | cmd.Execute() 9 | } 10 | -------------------------------------------------------------------------------- /rest/models/model_row_add.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // RowAdd defines a row to be added to a table 4 | type RowAdd struct { 5 | Columns []Column `json:"columns" validate:"required"` 6 | } 7 | -------------------------------------------------------------------------------- /rest/models/model_rows_response.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type RowsResponse struct { 4 | Success bool `json:"success,omitempty"` 5 | 6 | RowsModified int32 `json:"rowsModified,omitempty"` 7 | } 8 | -------------------------------------------------------------------------------- /data.json: -------------------------------------------------------------------------------- 1 | { 2 | "1": { 3 | "id": "1", 4 | "name": "Dan" 5 | }, 6 | "2": { 7 | "id": "2", 8 | "name": "Lee" 9 | }, 10 | "3": { 11 | "id": "3", 12 | "name": "Nick" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /rest/models/model_rows.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Rows struct { 4 | Rows []map[string]interface{} `json:"rows,omitempty"` 5 | PageState string `json:"pageState,omitempty"` 6 | Count int `json:"_count,omitempty"` 7 | } 8 | -------------------------------------------------------------------------------- /errors/conflict_error.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | type ConflictError struct { 4 | msg string 5 | } 6 | 7 | func (e *ConflictError) Error() string { 8 | return e.msg 9 | } 10 | 11 | func NewConflictError(text string) error { 12 | return &ConflictError{text} 13 | } 14 | -------------------------------------------------------------------------------- /errors/internal_error.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | type InternalError struct { 4 | msg string 5 | } 6 | 7 | func (e *InternalError) Error() string { 8 | return e.msg 9 | } 10 | 11 | func NewInternalError(text string) error { 12 | return &InternalError{text} 13 | } 14 | -------------------------------------------------------------------------------- /errors/not_found_error.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | type NotFoundError struct { 4 | msg string 5 | } 6 | 7 | func (e *NotFoundError) Error() string { 8 | return e.msg 9 | } 10 | 11 | func NewNotFoundError(text string) error { 12 | return &NotFoundError{text} 13 | } 14 | -------------------------------------------------------------------------------- /rest/models/model_column_update.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // ColumnUpdate changes the name of a primary key column and preserves the existing values. 4 | type ColumnUpdate struct { 5 | 6 | // NewName is the new name of the column. 7 | NewName string `json:"newName" validate:"required"` 8 | } 9 | -------------------------------------------------------------------------------- /rest/endpoint/v1/internal_error.go: -------------------------------------------------------------------------------- 1 | package endpoint 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | type InternalError struct { 8 | msg string 9 | } 10 | 11 | func (e *InternalError) Error() string { 12 | return e.msg 13 | } 14 | 15 | func (e *InternalError) StatusCode() int { 16 | return http.StatusNotFound 17 | } 18 | -------------------------------------------------------------------------------- /rest/models/model_column.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // Column is a column within a row to be added to a table 4 | type Column struct { 5 | Name *string `json:"name" validate:"required"` 6 | 7 | // The value to store in the column, can be either a literal or collection 8 | Value interface{} `json:"value" validate:"required"` 9 | } 10 | -------------------------------------------------------------------------------- /rest/models/model_error.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // A description of an error state 4 | type ModelError struct { 5 | 6 | // A human readable description of the error state 7 | Description string `json:"description,omitempty"` 8 | 9 | // The internal number referencing the error state 10 | InternalCode string `json:"internalCode,omitempty"` 11 | } 12 | -------------------------------------------------------------------------------- /auth/auth_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | ) 8 | 9 | func TestContextUserOrRole(t *testing.T) { 10 | assert.Equal(t, "", ContextUserOrRole(context.Background())) 11 | assert.Equal(t, "user1", 12 | ContextUserOrRole(WithContextUserOrRole(context.Background(), "user1"))) 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .DS_Store 3 | 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | cassandra-data-apis 11 | 12 | # Test binary, built with `go test -c` 13 | *.test 14 | 15 | # Output of the go coverage tool, specifically when used with LiteIDE 16 | *.out 17 | 18 | # Dependency directories (remove the comment below to include it) 19 | # vendor/ 20 | -------------------------------------------------------------------------------- /rest/models/model_table.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Table struct { 4 | Name string `json:"name,omitempty"` 5 | Keyspace string `json:"keyspace,omitempty"` 6 | ColumnDefinitions []ColumnDefinition `json:"columnDefinitions,omitempty"` 7 | PrimaryKey *PrimaryKey `json:"primaryKey,omitempty"` 8 | TableOptions *TableOptions `json:"tableOptions,omitempty"` 9 | } 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.14.2-stretch AS builder 2 | 3 | ENV CGO_ENABLED=0 4 | 5 | WORKDIR /build 6 | 7 | COPY go.mod go.sum ./ 8 | RUN go mod download 9 | 10 | COPY . . 11 | 12 | RUN go build 13 | RUN go test -v ./... 14 | 15 | RUN touch config.yaml 16 | 17 | FROM alpine:3.11 18 | 19 | WORKDIR /root 20 | 21 | COPY --from=builder /build/cassandra-data-apis . 22 | COPY --from=builder /build/config.yaml . 23 | 24 | CMD [ "/root/cassandra-data-apis", "--config", "/root/config.yaml" ] 25 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | image: Ubuntu 2 | stack: jdk 8, go 1.14, python 2 3 | build: off 4 | 5 | cache: 6 | - $HOME/.ccm/repository -> appveyor.yml, ci/ubuntu_tools.sh 7 | - $GOPATH/pkg/mod -> appveyor.yml, go.mod, go.sum 8 | - simulacron -> appveyor.yml, ci/ubuntu_tools.sh 9 | 10 | environment: 11 | matrix: 12 | - CCM_VERSION: "3.11.6" 13 | 14 | install: 15 | - source ./ci/ubuntu_tools.sh 16 | 17 | before_test: 18 | - ccm create test -v $CCM_VERSION 19 | - ccm remove 20 | 21 | test_script: 22 | - go test -v -p=1 -tags integration ./... 23 | -------------------------------------------------------------------------------- /auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import "context" 4 | 5 | type contextKey struct { 6 | name string 7 | } 8 | 9 | var authKey = &contextKey{"userOrRole"} 10 | 11 | func WithContextUserOrRole(ctx context.Context, userOrRole string) context.Context { 12 | if ctx == nil { 13 | ctx = context.Background() 14 | } 15 | return context.WithValue(ctx, authKey, userOrRole) 16 | } 17 | 18 | func ContextUserOrRole(ctx context.Context) string { 19 | if ctx != nil { 20 | if val, ok := ctx.Value(authKey).(string); ok { 21 | return val 22 | } 23 | 24 | } 25 | return "" 26 | } 27 | -------------------------------------------------------------------------------- /rest/models/model_rows_update.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // RowsUpdate defines an update operation on rows within a table. 4 | type RowsUpdate struct { 5 | Changeset []Changeset `json:"changeset" validate:"required"` 6 | } 7 | 8 | // Changeset is a column and associated value to be used when updating a row. 9 | type Changeset struct { 10 | // The name of the column to be updated. 11 | Column string `json:"column" validate:"required"` 12 | 13 | // The value for the column that will be updated for all matching rows. 14 | Value interface{} `json:"value" validate:"required"` 15 | } 16 | -------------------------------------------------------------------------------- /rest/models/model_query.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Query struct { 4 | ColumnNames []string `json:"columnNames,omitempty"` 5 | Filters []Filter `json:"filters" validate:"required"` 6 | OrderBy *ClusteringExpression `json:"orderBy,omitempty"` 7 | PageSize int `json:"pageSize,omitempty"` 8 | PageState string `json:"pageState,omitempty"` 9 | } 10 | 11 | type Filter struct { 12 | ColumnName string `json:"columnName" validate:"required"` 13 | Operator string `json:"operator" validate:"required,oneof=eq notEq gt gte lt lte in"` 14 | Value []interface{} `json:"value" validate:"required"` 15 | } 16 | -------------------------------------------------------------------------------- /ci/ubuntu_tools.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Setup tools for Ubuntu" 4 | 5 | # Install CCM 6 | git clone --branch master --single-branch https://github.com/riptano/ccm.git 7 | pushd ccm || exit 8 | sudo python setup.py install 9 | popd 10 | ccm status || true 11 | 12 | export CCM_PATH="$(pwd)/ccm" 13 | export SIMULACRON_PATH="$(pwd)/simulacron/simulacron.jar" 14 | 15 | if [ ! -f "$SIMULACRON_PATH" ]; then 16 | mkdir simulacron 17 | pushd simulacron 18 | wget https://github.com/datastax/simulacron/releases/download/0.9.0/simulacron-standalone-0.9.0.jar 19 | chmod uog+rw simulacron-standalone-0.9.0.jar 20 | ln -s simulacron-standalone-0.9.0.jar simulacron.jar 21 | popd 22 | fi -------------------------------------------------------------------------------- /rest/route_generator.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "github.com/datastax/cassandra-data-apis/config" 5 | "github.com/datastax/cassandra-data-apis/db" 6 | restEndpointV1 "github.com/datastax/cassandra-data-apis/rest/endpoint/v1" 7 | "github.com/datastax/cassandra-data-apis/types" 8 | ) 9 | 10 | type RouteGenerator struct { 11 | dbClient *db.Db 12 | config config.Config 13 | } 14 | 15 | func NewRouteGenerator( 16 | dbClient *db.Db, 17 | cfg config.Config, 18 | ) *RouteGenerator { 19 | return &RouteGenerator{ 20 | dbClient: dbClient, 21 | config: cfg, 22 | } 23 | } 24 | 25 | func (g *RouteGenerator) Routes(prefix string, operations config.SchemaOperations, singleKs string) []types.Route { 26 | return restEndpointV1.Routes(prefix, operations, singleKs, g.config, g.dbClient) 27 | } 28 | -------------------------------------------------------------------------------- /db/keyspace.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type CreateKeyspaceInfo struct { 8 | Name string 9 | DCReplicas map[string]int 10 | IfNotExists bool 11 | } 12 | 13 | type DropKeyspaceInfo struct { 14 | Name string 15 | IfExists bool 16 | } 17 | 18 | func (db *Db) CreateKeyspace(info *CreateKeyspaceInfo, options *QueryOptions) error { 19 | dcs := "" 20 | for name, replicas := range info.DCReplicas { 21 | dcs += fmt.Sprintf(", '%s': %d", name, replicas) 22 | } 23 | 24 | query := fmt.Sprintf(`CREATE KEYSPACE %s"%s" WITH REPLICATION = { 'class': 'NetworkTopologyStrategy', %s }`, 25 | ifNotExistsStr(info.IfNotExists), info.Name, dcs[2:]) 26 | return db.session.ChangeSchema(query, options) 27 | } 28 | 29 | func (db *Db) DropKeyspace(info *DropKeyspaceInfo, options *QueryOptions) error { 30 | query := fmt.Sprintf(`DROP KEYSPACE %s"%s"`, ifExistsStr(info.IfExists), info.Name) 31 | return db.session.ChangeSchema(query, options) 32 | } 33 | -------------------------------------------------------------------------------- /errors/error_utils.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | 7 | ut "github.com/go-playground/universal-translator" 8 | "github.com/go-playground/validator/v10" 9 | ) 10 | 11 | // TranslateValidatorError takes an error from the go-playground validator (internally just a map of errors) and converts it into a string 12 | // which can then be used to create a new error. The purpose of this function is to get around the fact that go-playground 13 | // validator creates errors that are not in a user friendly format. 14 | func TranslateValidatorError(err error, trans ut.Translator) error { 15 | switch err.(type) { 16 | case validator.ValidationErrors: 17 | errs := (err.(validator.ValidationErrors)).Translate(trans) 18 | 19 | vals := make([]string, 0, len(errs)) 20 | 21 | for _, value := range errs { 22 | vals = append(vals, value) 23 | } 24 | 25 | return errors.New(strings.Join(vals, " ")) 26 | default: 27 | return err 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /config/schema_operations_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestOperationsSetAndClear(t *testing.T) { 9 | var op SchemaOperations 10 | 11 | assert.Equal(t, op, SchemaOperations(0)) 12 | assert.False(t, op.IsSupported(TableCreate)) 13 | 14 | op.Set(TableCreate | TableDrop) 15 | assert.True(t, op.IsSupported(TableCreate)) 16 | assert.True(t, op.IsSupported(TableDrop)) 17 | 18 | op.Clear(TableCreate) 19 | assert.False(t, op.IsSupported(TableCreate)) 20 | assert.True(t, op.IsSupported(TableDrop)) 21 | } 22 | 23 | func TestOperationsAdd(t *testing.T) { 24 | var op SchemaOperations 25 | 26 | assert.Equal(t, op, SchemaOperations(0)) 27 | 28 | op.Add("TableCreate", "TableDrop", "TableAlterAdd", "TableAlterDrop", "KeyspaceCreate", "KeyspaceDrop") 29 | assert.True(t, op.IsSupported(TableCreate)) 30 | assert.True(t, op.IsSupported(TableDrop)) 31 | assert.True(t, op.IsSupported(TableAlterAdd)) 32 | assert.True(t, op.IsSupported(TableAlterDrop)) 33 | assert.True(t, op.IsSupported(KeyspaceCreate)) 34 | assert.True(t, op.IsSupported(KeyspaceDrop)) 35 | } 36 | -------------------------------------------------------------------------------- /db/host_selection_integration_test.go: -------------------------------------------------------------------------------- 1 | // +build integration simulated 2 | 3 | package db 4 | 5 | import ( 6 | "github.com/datastax/cassandra-data-apis/internal/testutil" 7 | . "github.com/onsi/ginkgo" 8 | . "github.com/onsi/gomega" 9 | ) 10 | 11 | var _ = Describe("NewDb()", func() { 12 | testutil.EnsureSimulacronCluster() 13 | 14 | It("Should only target local DC", func() { 15 | db, err := NewDb(Config{}, "", testutil.SimulacronStartIp) 16 | Expect(err).NotTo(HaveOccurred()) 17 | query := "SELECT * FROM ks1.tbl1" 18 | length := 100 19 | for i := 0; i < length; i++ { 20 | _, err := db.session.ExecuteIter(query, nil) 21 | Expect(err).NotTo(HaveOccurred()) 22 | } 23 | dc1Logs := testutil.GetQueryLogs(0) 24 | Expect(dc1Logs.DataCenters).To(HaveLen(1)) 25 | dc1Queries := testutil.CountLogMatches(dc1Logs.DataCenters[0].Nodes, query) 26 | 27 | // All executions to be made on DC1 28 | Expect(dc1Queries).To(Equal(testutil.QueryMatches{ 29 | Prepare: 3, // One per node 30 | Execute: length, 31 | })) 32 | 33 | dc2Logs := testutil.GetQueryLogs(1) 34 | Expect(dc2Logs.DataCenters).To(HaveLen(1)) 35 | dc2Queries := testutil.CountLogMatches(dc2Logs.DataCenters[0].Nodes, query) 36 | 37 | // No executions on DC2 38 | Expect(dc2Queries.Execute).To(BeZero()) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /internal/testutil/schemas/datatypes/schema.cql: -------------------------------------------------------------------------------- 1 | // A sample schema containing data types that are supported 2 | 3 | // Drop and recreate the keyspace 4 | DROP KEYSPACE IF EXISTS datatypes; 5 | CREATE KEYSPACE datatypes WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 }; 6 | 7 | use datatypes; 8 | 9 | CREATE TABLE scalars ( 10 | id uuid PRIMARY KEY, 11 | text_col text, 12 | varchar_col varchar, 13 | ascii_col ascii, 14 | tinyint_col tinyint, 15 | smallint_col smallint, 16 | int_col int, 17 | bigint_col bigint, 18 | varint_col varint, 19 | decimal_col decimal, 20 | float_col float, 21 | double_col double, 22 | boolean_col boolean, 23 | uuid_col uuid, 24 | timeuuid_col timeuuid, 25 | blob_col blob, 26 | inet_col inet, 27 | timestamp_col timestamp, 28 | time_col time 29 | ); 30 | 31 | CREATE TABLE collections ( 32 | id uuid PRIMARY KEY , 33 | list_text list, 34 | list_float frozen>, 35 | set_uuid set, 36 | set_int frozen>, 37 | map_bigint_blob map, 38 | ); 39 | 40 | CREATE TABLE table_static ( 41 | id1 uuid, 42 | id2 int, 43 | value int, 44 | value_static int static, 45 | PRIMARY KEY (id1, id2) 46 | ); 47 | 48 | CREATE TABLE sample_table (id int PRIMARY KEY); -------------------------------------------------------------------------------- /config/schema_operations.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type SchemaOperations int 8 | 9 | const ( 10 | TableCreate SchemaOperations = 1 << iota 11 | TableDrop 12 | TableAlterAdd 13 | TableAlterDrop 14 | KeyspaceCreate 15 | KeyspaceDrop 16 | ) 17 | 18 | const AllSchemaOperations = TableCreate | TableDrop | TableAlterAdd | TableAlterDrop | KeyspaceCreate | KeyspaceDrop 19 | 20 | func Ops(ops ...string) (SchemaOperations, error) { 21 | var o SchemaOperations 22 | err := o.Add(ops...) 23 | return o, err 24 | } 25 | 26 | func (o *SchemaOperations) Set(ops SchemaOperations) { *o |= ops } 27 | func (o *SchemaOperations) Clear(ops SchemaOperations) { *o &= ^ops } 28 | func (o SchemaOperations) IsSupported(ops SchemaOperations) bool { return o&ops != 0 } 29 | 30 | func (o *SchemaOperations) Add(ops ...string) error { 31 | for _, op := range ops { 32 | switch op { 33 | case "TableCreate": 34 | o.Set(TableCreate) 35 | case "TableDrop": 36 | o.Set(TableDrop) 37 | case "TableAlterAdd": 38 | o.Set(TableAlterAdd) 39 | case "TableAlterDrop": 40 | o.Set(TableAlterDrop) 41 | case "KeyspaceCreate": 42 | o.Set(KeyspaceCreate) 43 | case "KeyspaceDrop": 44 | o.Set(KeyspaceDrop) 45 | default: 46 | return fmt.Errorf("invalid operation: %s", op) 47 | } 48 | } 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/datastax/cassandra-data-apis 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/fsnotify/fsnotify v1.4.9 // indirect 7 | github.com/go-playground/locales v0.13.0 8 | github.com/go-playground/universal-translator v0.17.0 9 | github.com/go-playground/validator/v10 v10.2.0 10 | github.com/gocql/gocql v0.0.0-20200624222514-34081eda590e 11 | github.com/graphql-go/graphql v0.7.9 12 | github.com/iancoleman/strcase v0.0.0-20191112232945-16388991a334 13 | github.com/julienschmidt/httprouter v1.3.0 14 | github.com/mitchellh/mapstructure v1.2.2 15 | github.com/onsi/ginkgo v1.12.0 16 | github.com/onsi/gomega v1.9.0 17 | github.com/pelletier/go-toml v1.7.0 // indirect 18 | github.com/sergi/go-diff v1.1.0 19 | github.com/spf13/afero v1.2.2 // indirect 20 | github.com/spf13/cast v1.3.1 // indirect 21 | github.com/spf13/cobra v0.0.7 22 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 23 | github.com/spf13/pflag v1.0.5 24 | github.com/spf13/viper v1.6.2 25 | github.com/stretchr/objx v0.2.0 // indirect 26 | github.com/stretchr/testify v1.5.1 27 | go.uber.org/atomic v1.6.0 28 | go.uber.org/zap v1.14.1 29 | golang.org/x/sys v0.0.0-20200331124033-c3d80250170d // indirect 30 | golang.org/x/text v0.3.2 // indirect 31 | gopkg.in/inf.v0 v0.9.1 32 | gopkg.in/ini.v1 v1.55.0 // indirect 33 | gopkg.in/yaml.v2 v2.2.8 // indirect 34 | ) 35 | 36 | replace github.com/graphql-go/graphql => github.com/riptano/graphql-go v0.7.9-null 37 | -------------------------------------------------------------------------------- /graphql/updater_test.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "github.com/datastax/cassandra-data-apis/config" 5 | "github.com/datastax/cassandra-data-apis/db" 6 | "github.com/datastax/cassandra-data-apis/log" 7 | "github.com/gocql/gocql" 8 | "github.com/stretchr/testify/assert" 9 | "go.uber.org/zap" 10 | "testing" 11 | "time" 12 | ) 13 | 14 | func TestSchemaUpdater_Update(t *testing.T) { 15 | sessionMock := db.NewSessionMock() 16 | schemaGen := NewSchemaGenerator(db.NewDbWithSession(sessionMock), config.NewConfigMock().Default()) 17 | 18 | keyspace := "store" 19 | 20 | // Initial schema 21 | sessionMock.AddKeyspace(db.NewKeyspaceMock( 22 | keyspace, map[string][]*gocql.ColumnMetadata{ 23 | "books": db.BooksColumnsMock, 24 | })).Once() 25 | 26 | sessionMock.AddViews(nil) 27 | 28 | updater, err := NewUpdater(schemaGen, "store", 10*time.Second, log.NewZapLogger(zap.NewExample())) 29 | assert.NoError(t, err, "unable to create updater") 30 | 31 | assert.Contains(t, updater.Schema(keyspace).QueryType().Fields(), "books") 32 | assert.NotContains(t, updater.Schema(keyspace).QueryType().Fields(), "newTable1") 33 | 34 | // Add new table 35 | sessionMock.AddKeyspace(db.NewKeyspaceMock( 36 | "store", map[string][]*gocql.ColumnMetadata{ 37 | "books": db.BooksColumnsMock, 38 | "newTable1": db.BooksColumnsMock, 39 | })).Once() 40 | 41 | updater.update() 42 | assert.Contains(t, updater.Schema(keyspace).QueryType().Fields(), "newTable1") 43 | } 44 | -------------------------------------------------------------------------------- /rest/endpoint/v1/response.go: -------------------------------------------------------------------------------- 1 | package endpoint 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | m "github.com/datastax/cassandra-data-apis/rest/models" 8 | ) 9 | 10 | // RespondJSONObjectWithCode writes the object and status header to the response. Important to note that if this is being 11 | // used for an error case then an empty return will need to immediately follow the call to this function 12 | func RespondJSONObjectWithCode(w http.ResponseWriter, code int, obj interface{}) { 13 | setCommonHeaders(w) 14 | var err error 15 | var jsonBytes []byte 16 | if obj != nil { 17 | jsonBytes, err = json.Marshal(obj) 18 | } 19 | writeJSONBytes(w, jsonBytes, err, code) 20 | } 21 | 22 | func writeJSONBytes(w http.ResponseWriter, jsonBytes []byte, err error, code int) { 23 | if err != nil { 24 | RespondWithError(w, "Unable to marshal response", http.StatusInternalServerError) 25 | } 26 | 27 | w.WriteHeader(code) 28 | if jsonBytes != nil { 29 | _, _ = w.Write(jsonBytes) 30 | } 31 | } 32 | 33 | func RespondWithError(w http.ResponseWriter, message string, code int) { 34 | requestError := m.ModelError{ 35 | Description: message, 36 | } 37 | 38 | RespondJSONObjectWithCode(w, code, requestError) 39 | } 40 | 41 | func setCommonHeaders(w http.ResponseWriter) { 42 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 43 | } 44 | 45 | func RespondWithKeyspaceNotAllowed(w http.ResponseWriter) { 46 | RespondWithError(w, "keyspace not found", http.StatusBadRequest) 47 | } 48 | -------------------------------------------------------------------------------- /types/types.go: -------------------------------------------------------------------------------- 1 | // types package contains the public API types 2 | // that are shared between both REST and GraphQL 3 | package types 4 | 5 | import "net/http" 6 | 7 | type ModificationResult struct { 8 | Applied bool `json:"applied"` 9 | Value map[string]interface{} `json:"value"` 10 | } 11 | 12 | type QueryResult struct { 13 | PageState string `json:"pageState"` 14 | Values []map[string]interface{} `json:"values"` 15 | } 16 | 17 | type QueryOptions struct { 18 | PageState string `json:"pageState"` 19 | PageSize int `json:"pageSize"` 20 | Limit int `json:"limit"` 21 | Consistency int `json:"consistency"` 22 | SerialConsistency int `json:"serialConsistency"` 23 | } 24 | 25 | type MutationOptions struct { 26 | TTL int `json:"ttl"` 27 | Consistency int `json:"consistency"` 28 | SerialConsistency int `json:"serialConsistency"` 29 | } 30 | 31 | type ConditionItem struct { 32 | Column string `json:"column"` // json representation using for error information only 33 | Operator string `json:"operator"` 34 | Value interface{} `json:"value"` 35 | } 36 | 37 | // CqlOperators contains the CQL operator for a given "graphql" operator 38 | var CqlOperators = map[string]string{ 39 | "eq": "=", 40 | "notEq": "!=", 41 | "gt": ">", 42 | "gte": ">=", 43 | "lt": "<", 44 | "lte": "<=", 45 | "in": "IN", 46 | } 47 | 48 | // Route represents a request route to be served 49 | type Route struct { 50 | Method string 51 | Pattern string 52 | Handler http.Handler 53 | } 54 | -------------------------------------------------------------------------------- /internal/testutil/schemas/quirky/schema.cql: -------------------------------------------------------------------------------- 1 | // A sample schema containing data types that are not supported and edge cases 2 | 3 | // Drop and recreate the keyspace 4 | DROP KEYSPACE IF EXISTS quirky; 5 | CREATE KEYSPACE quirky WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 }; 6 | 7 | use quirky; 8 | 9 | CREATE TABLE valid_sample (id uuid PRIMARY KEY, col_one text, col_two int); 10 | 11 | CREATE TYPE address (line1 text, line2 text); 12 | 13 | // UDTs are not supported 14 | CREATE TABLE tbl_udt (id int PRIMARY KEY, address frozen
); 15 | 16 | // Tuples are not supported yet 17 | CREATE TABLE tbl_tuple (id int PRIMARY KEY, location tuple); 18 | 19 | // CQL type duration is not supported yet 20 | CREATE TABLE tbl_duration (id int PRIMARY KEY, d duration); 21 | 22 | // Reserved names 23 | CREATE TABLE basic_type (id int PRIMARY KEY, value text); 24 | CREATE TABLE column (id int PRIMARY KEY, value text); 25 | CREATE TABLE column_custom (id int PRIMARY KEY, value text); 26 | CREATE TABLE consistency (id int PRIMARY KEY, value text); 27 | CREATE TABLE data_type (id int PRIMARY KEY, value text); 28 | 29 | // Table names that generate the same type name 30 | CREATE TABLE tester_abc (id int PRIMARY KEY, value text); 31 | CREATE TABLE "testerAbc" (id int PRIMARY KEY, value text); 32 | 33 | CREATE TABLE "WEIRD_CASE" ("ID" int PRIMARY KEY, "ABCdef" text, "QA_data" text, "abc_XYZ" text); 34 | 35 | // Views shouldn't allow direct mutations 36 | CREATE TABLE table_with_view (id int PRIMARY KEY, value text); 37 | CREATE MATERIALIZED VIEW tables_view AS SELECT value FROM table_with_view WHERE value IS NOT NULL PRIMARY KEY(value, id); 38 | 39 | -------------------------------------------------------------------------------- /graphql/operators_input_types.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gocql/gocql" 6 | "github.com/graphql-go/graphql" 7 | ) 8 | 9 | var stringOperatorType = operatorType(graphql.String) 10 | var intOperatorType = operatorType(graphql.Int) 11 | var floatOperatorType = operatorType(graphql.Float) 12 | 13 | var operatorsInputTypes = map[gocql.Type]*graphql.InputObject{ 14 | gocql.TypeInt: intOperatorType, 15 | gocql.TypeTinyInt: intOperatorType, 16 | gocql.TypeSmallInt: intOperatorType, 17 | gocql.TypeText: stringOperatorType, 18 | gocql.TypeVarchar: stringOperatorType, 19 | gocql.TypeFloat: floatOperatorType, 20 | gocql.TypeDouble: floatOperatorType, 21 | gocql.TypeUUID: operatorType(uuid), 22 | gocql.TypeTimestamp: operatorType(timestamp), 23 | gocql.TypeTimeUUID: operatorType(timeuuid), //TODO: Apply max/min to timeuuid 24 | gocql.TypeInet: operatorType(ip), 25 | gocql.TypeBigInt: operatorType(bigint), 26 | gocql.TypeDecimal: operatorType(decimal), 27 | gocql.TypeVarint: operatorType(varint), 28 | gocql.TypeBlob: operatorType(blob), 29 | } 30 | 31 | func operatorType(graphqlType graphql.Type) *graphql.InputObject { 32 | return graphql.NewInputObject(graphql.InputObjectConfig{ 33 | Description: fmt.Sprintf("Input type to be used in filter queries for the %s type.", graphqlType.Name()), 34 | Name: graphqlType.Name() + "FilterInput", 35 | Fields: graphql.InputObjectConfigFieldMap{ 36 | "eq": {Type: graphqlType}, 37 | "notEq": {Type: graphqlType}, 38 | "gt": {Type: graphqlType}, 39 | "gte": {Type: graphqlType}, 40 | "lt": {Type: graphqlType}, 41 | "lte": {Type: graphqlType}, 42 | "in": {Type: graphql.NewList(graphqlType)}, 43 | }, 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /config/mocks.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/datastax/cassandra-data-apis/log" 5 | "github.com/stretchr/testify/mock" 6 | "go.uber.org/zap" 7 | "time" 8 | ) 9 | 10 | type ConfigMock struct { 11 | mock.Mock 12 | } 13 | 14 | func NewConfigMock() *ConfigMock { 15 | return &ConfigMock{} 16 | } 17 | 18 | func (o *ConfigMock) Default() *ConfigMock { 19 | o.On("ExcludedKeyspaces").Return([]string{"system"}) 20 | o.On("SchemaUpdateInterval").Return(10 * time.Second) 21 | o.On("Naming").Return(NamingConventionFn(NewDefaultNaming)) 22 | o.On("UseUserOrRoleAuth").Return(false) 23 | o.On("Logger").Return(log.NewZapLogger(zap.NewExample())) 24 | return o 25 | } 26 | 27 | func (o *ConfigMock) ExcludedKeyspaces() []string { 28 | args := o.Called() 29 | return args.Get(0).([]string) 30 | } 31 | 32 | func (o *ConfigMock) SchemaUpdateInterval() time.Duration { 33 | args := o.Called() 34 | return args.Get(0).(time.Duration) 35 | } 36 | 37 | func (o *ConfigMock) Naming() NamingConventionFn { 38 | args := o.Called() 39 | return args.Get(0).(NamingConventionFn) 40 | } 41 | 42 | func (o *ConfigMock) UseUserOrRoleAuth() bool { 43 | args := o.Called() 44 | return args.Get(0).(bool) 45 | } 46 | 47 | func (o *ConfigMock) Logger() log.Logger { 48 | args := o.Called() 49 | return args.Get(0).(log.Logger) 50 | } 51 | 52 | func (o *ConfigMock) RouterInfo() HttpRouterInfo { 53 | args := o.Called() 54 | return args.Get(0).(HttpRouterInfo) 55 | } 56 | 57 | type KeyspaceNamingInfoMock struct { 58 | mock.Mock 59 | } 60 | 61 | func NewKeyspaceNamingInfoMock() *KeyspaceNamingInfoMock { 62 | return &KeyspaceNamingInfoMock{} 63 | } 64 | 65 | func (o *KeyspaceNamingInfoMock) Tables() map[string][]string { 66 | args := o.Called() 67 | return args.Get(0).(map[string][]string) 68 | } 69 | -------------------------------------------------------------------------------- /internal/testutil/schemas/quirky/quirky.go: -------------------------------------------------------------------------------- 1 | package quirky 2 | 3 | import ( 4 | "fmt" 5 | "github.com/datastax/cassandra-data-apis/internal/testutil/schemas" 6 | "github.com/datastax/cassandra-data-apis/types" 7 | "github.com/iancoleman/strcase" 8 | . "github.com/onsi/gomega" 9 | ) 10 | 11 | var intId = 0 12 | 13 | func newIntId() int { 14 | intId++ 15 | return intId 16 | } 17 | 18 | func InsertAndSelect(routes []types.Route, name string) int { 19 | insertQuery := `mutation { 20 | insert%s(value:{id:%d, value:"%s"}) { 21 | applied 22 | } 23 | }` 24 | selectQuery := `query { 25 | %s(value:{id:%d}) { 26 | values { 27 | id 28 | value 29 | } 30 | } 31 | }` 32 | 33 | id := newIntId() 34 | value := schemas.NewUuid() 35 | schemas.ExecutePost(routes, "/graphql", fmt.Sprintf(insertQuery, name, id, value)) 36 | buffer := schemas.ExecutePost(routes, "/graphql", fmt.Sprintf(selectQuery, strcase.ToLowerCamel(name), id)) 37 | values := schemas.DecodeDataAsSliceOfMaps(buffer, strcase.ToLowerCamel(name), "values") 38 | Expect(values[0]["value"]).To(Equal(value)) 39 | return id 40 | } 41 | 42 | func InsertWeirdCase(routes []types.Route, id int) { 43 | query := `mutation { 44 | insertWEIRDCASE(value: { id: %d, aBCdef: "one", qAData: "two", abcXYZ: "three" }) { 45 | applied 46 | } 47 | }` 48 | buffer := schemas.ExecutePost(routes, "/graphql", fmt.Sprintf(query, id)) 49 | data := schemas.DecodeData(buffer, "insertWEIRDCASE") 50 | Expect(data["applied"]).To(Equal(true)) 51 | } 52 | 53 | func SelectWeirdCase(routes []types.Route, id int) { 54 | query := `{ 55 | wEIRDCASE(value: {id: %d }) { 56 | values { aBCdef, abcXYZ, qAData } 57 | } 58 | }` 59 | buffer := schemas.ExecutePost(routes, "/graphql", fmt.Sprintf(query, id)) 60 | data := schemas.DecodeData(buffer, "wEIRDCASE") 61 | Expect(data["values"]).To(ConsistOf(map[string]interface{}{"aBCdef": "one", "abcXYZ": "three", "qAData": "two"})) 62 | } 63 | -------------------------------------------------------------------------------- /graphql/playground.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "fmt" 5 | "github.com/julienschmidt/httprouter" 6 | "net/http" 7 | ) 8 | 9 | func GetPlaygroundHandle(defaultEndpointUrl string) httprouter.Handle { 10 | return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { 11 | html := ` 12 | 13 | 14 | 15 | 16 | 17 | 18 | GraphQL Playground 19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 57 | 58 |
Loading 59 | GraphQL Playground 60 |
61 |
62 | 67 | 68 | 69 | 70 | ` 71 | html = fmt.Sprintf(html, defaultEndpointUrl) 72 | fmt.Fprint(w, html) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /db/host_selection.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "github.com/gocql/gocql" 5 | "sync/atomic" 6 | ) 7 | 8 | type dcInferringPolicy struct { 9 | childPolicy atomic.Value 10 | isLocalDcSet int32 11 | } 12 | 13 | type childPolicyWrapper struct { 14 | policy gocql.HostSelectionPolicy 15 | } 16 | 17 | func NewDefaultHostSelectionPolicy() gocql.HostSelectionPolicy { 18 | return gocql.TokenAwareHostPolicy(NewDcInferringPolicy(), gocql.ShuffleReplicas()) 19 | } 20 | 21 | func NewDcInferringPolicy() *dcInferringPolicy { 22 | policy := dcInferringPolicy{} 23 | policy.childPolicy.Store(childPolicyWrapper{gocql.RoundRobinHostPolicy()}) 24 | return &policy 25 | } 26 | 27 | func (p *dcInferringPolicy) AddHost(host *gocql.HostInfo) { 28 | if atomic.CompareAndSwapInt32(&p.isLocalDcSet, 0, 1) { 29 | childPolicy := gocql.DCAwareRoundRobinPolicy(host.DataCenter()) 30 | p.childPolicy.Store(childPolicyWrapper{childPolicy}) 31 | childPolicy.AddHost(host) 32 | } else { 33 | p.getChildPolicy().AddHost(host) 34 | } 35 | } 36 | 37 | func (p *dcInferringPolicy) getChildPolicy() gocql.HostSelectionPolicy { 38 | wrapper := p.childPolicy.Load().(childPolicyWrapper) 39 | return wrapper.policy 40 | } 41 | 42 | func (p *dcInferringPolicy) RemoveHost(host *gocql.HostInfo) { 43 | p.getChildPolicy().RemoveHost(host) 44 | } 45 | 46 | func (p *dcInferringPolicy) HostUp(host *gocql.HostInfo) { 47 | p.getChildPolicy().HostUp(host) 48 | } 49 | 50 | func (p *dcInferringPolicy) HostDown(host *gocql.HostInfo) { 51 | p.getChildPolicy().HostDown(host) 52 | } 53 | 54 | func (p *dcInferringPolicy) SetPartitioner(partitioner string) { 55 | p.getChildPolicy().SetPartitioner(partitioner) 56 | } 57 | 58 | func (p *dcInferringPolicy) KeyspaceChanged(e gocql.KeyspaceUpdateEvent) { 59 | p.getChildPolicy().KeyspaceChanged(e) 60 | } 61 | 62 | func (p *dcInferringPolicy) Init(*gocql.Session) { 63 | // TAP parent policy does not call init on "fallback policy" 64 | } 65 | 66 | func (p *dcInferringPolicy) IsLocal(host *gocql.HostInfo) bool { 67 | return p.getChildPolicy().IsLocal(host) 68 | } 69 | 70 | func (p *dcInferringPolicy) Pick(query gocql.ExecutableQuery) gocql.NextHost { 71 | return p.getChildPolicy().Pick(query) 72 | } 73 | -------------------------------------------------------------------------------- /graphql/updater.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "context" 5 | "github.com/datastax/cassandra-data-apis/log" 6 | "github.com/graphql-go/graphql" 7 | "sync" 8 | "time" 9 | ) 10 | 11 | type SchemaUpdater struct { 12 | ctx context.Context 13 | cancel context.CancelFunc 14 | mutex sync.Mutex 15 | updateInterval time.Duration 16 | schemas *map[string]*graphql.Schema 17 | schemaGen *SchemaGenerator 18 | singleKeyspace string 19 | logger log.Logger 20 | } 21 | 22 | func (su *SchemaUpdater) Schema(keyspace string) *graphql.Schema { 23 | // This should be pretty fast, but an atomic pointer swap wouldn't require a lock here 24 | su.mutex.Lock() 25 | schemas := *su.schemas 26 | su.mutex.Unlock() 27 | return schemas[keyspace] 28 | } 29 | 30 | func NewUpdater( 31 | schemaGen *SchemaGenerator, 32 | singleKeyspace string, 33 | updateInterval time.Duration, 34 | logger log.Logger, 35 | ) (*SchemaUpdater, error) { 36 | schemas, err := schemaGen.BuildSchemas(singleKeyspace) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | updater := &SchemaUpdater{ 42 | ctx: nil, 43 | cancel: nil, 44 | mutex: sync.Mutex{}, 45 | updateInterval: updateInterval, 46 | schemas: &schemas, 47 | schemaGen: schemaGen, 48 | singleKeyspace: singleKeyspace, 49 | logger: logger, 50 | } 51 | 52 | return updater, nil 53 | } 54 | 55 | func (su *SchemaUpdater) Start() { 56 | su.ctx, su.cancel = context.WithCancel(context.Background()) 57 | for { 58 | su.update() 59 | if !su.sleep() { 60 | return 61 | } 62 | } 63 | } 64 | 65 | func (su *SchemaUpdater) Stop() { 66 | su.cancel() 67 | } 68 | 69 | func (su *SchemaUpdater) update() { 70 | schemas, err := su.schemaGen.BuildSchemas(su.singleKeyspace) 71 | if err != nil { 72 | su.logger.Error("unable to build graphql schema for keyspace", "error", err) 73 | } else { 74 | su.mutex.Lock() 75 | su.schemas = &schemas 76 | su.mutex.Unlock() 77 | } 78 | } 79 | 80 | func (su *SchemaUpdater) sleep() bool { 81 | select { 82 | case <-time.After(su.updateInterval): 83 | return true 84 | case <-su.ctx.Done(): 85 | return false 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /log/logger.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "go.uber.org/zap" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | type Logger interface { 10 | Debug(msg string, keyAndValues ...interface{}) 11 | Info(msg string, keyAndValues ...interface{}) 12 | Warn(msg string, keyAndValues ...interface{}) 13 | Error(msg string, keyAndValues ...interface{}) 14 | Fatal(msg string, keyAndValues ...interface{}) 15 | } 16 | 17 | type statusRecorder struct { 18 | http.ResponseWriter 19 | Status int 20 | } 21 | 22 | func (rec *statusRecorder) WriteHeader(code int) { 23 | rec.Status = code 24 | rec.ResponseWriter.WriteHeader(code) 25 | } 26 | 27 | type LoggingHandler struct { 28 | handler http.Handler 29 | logger Logger 30 | } 31 | 32 | func NewLoggingHandler(handler http.Handler, logger Logger) *LoggingHandler { 33 | return &LoggingHandler{ 34 | handler, 35 | logger, 36 | } 37 | } 38 | 39 | func (h *LoggingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 40 | start := time.Now() 41 | 42 | statusRec := &statusRecorder{ResponseWriter: w, Status: http.StatusOK} 43 | 44 | h.handler.ServeHTTP(statusRec, r) 45 | 46 | if statusRec.Status < 300 { 47 | h.logger.Info("processed request", 48 | "requestURI", r.RequestURI, 49 | "method", r.Method, 50 | "status", statusRec.Status, 51 | "elapsed", time.Since(start)) 52 | } else { 53 | h.logger.Error("error processing request", 54 | "requestURI", r.RequestURI, 55 | "method", r.Method, 56 | "status", statusRec.Status, 57 | "elapsed", time.Since(start)) 58 | } 59 | } 60 | 61 | type ZapLogger struct { 62 | inner *zap.SugaredLogger 63 | } 64 | 65 | func NewZapLogger(log *zap.Logger) ZapLogger { 66 | return ZapLogger{inner: log.Sugar()} 67 | } 68 | 69 | func (l ZapLogger) Debug(msg string, keyAndValues ...interface{}) { 70 | l.inner.Debugw(msg, keyAndValues...) 71 | } 72 | 73 | func (l ZapLogger) Info(msg string, keyAndValues ...interface{}) { 74 | l.inner.Infow(msg, keyAndValues...) 75 | } 76 | 77 | func (l ZapLogger) Warn(msg string, keyAndValues ...interface{}) { 78 | l.inner.Warnw(msg, keyAndValues...) 79 | } 80 | 81 | func (l ZapLogger) Error(msg string, keyAndValues ...interface{}) { 82 | l.inner.Errorw(msg, keyAndValues...) 83 | } 84 | 85 | func (l ZapLogger) Fatal(msg string, keyAndValues ...interface{}) { 86 | l.inner.Fatalw(msg, keyAndValues...) 87 | } 88 | -------------------------------------------------------------------------------- /rest/models/model_table_add.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // TableAdd defines the table to be added to an existing keyspace 4 | type TableAdd struct { 5 | Name string `validate:"required"` 6 | 7 | // Attempting to create an existing table returns an error unless the IF NOT EXISTS option is used. If the option is 8 | // used, the statement if a no-op is the table already exists. 9 | IfNotExists bool `json:"ifNotExists,omitempty"` 10 | 11 | ColumnDefinitions []ColumnDefinition `json:"columnDefinitions,omitempty"` 12 | 13 | // Defines a column list for the primary key. Can be either a single column, compound primary key, or composite partition key. 14 | PrimaryKey *PrimaryKey `json:"primaryKey" validate:"required"` 15 | 16 | TableOptions *TableOptions `json:"tableOptions,omitempty"` 17 | } 18 | 19 | // PrimaryKey defines a column list for the primary key. Can be either a single column, compound primary key, or composite partition 20 | // key. Provide multiple columns for the partition key to define a composite partition key. 21 | type PrimaryKey struct { 22 | 23 | // The column(s) that will constitute the partition key. 24 | PartitionKey []string `json:"partitionKey" validate:"required"` 25 | 26 | // The column(s) that will constitute the clustering key. 27 | ClusteringKey []string `json:"clusteringKey,omitempty"` 28 | } 29 | 30 | // TableOptions are various properties that tune data handling, including I/O operations, compression, and compaction. 31 | type TableOptions struct { 32 | 33 | // TTL (Time To Live) in seconds, where zero is disabled. The maximum configurable value is 630720000 (20 years). If 34 | // the value is greater than zero, TTL is enabled for the entire table and an expiration timestamp is added to each 35 | // column. A new TTL timestamp is calculated each time the data is updated and the row is removed after all the data expires. 36 | DefaultTimeToLive *int32 `json:"defaultTimeToLive,omitempty" validate:"gte=0,lte=630720000"` 37 | 38 | ClusteringExpression []ClusteringExpression `json:"clusteringExpression,omitempty"` 39 | } 40 | 41 | // ClusteringExpression allows for ordering rows so that storage is able to make use of the on-disk sorting of columns. Specifying 42 | // order can make query results more efficient. 43 | type ClusteringExpression struct { 44 | Column *string `json:"column" validate:"required"` 45 | Order *string `json:"order" validate:"required"` 46 | } 47 | -------------------------------------------------------------------------------- /rest/models/model_column_definition.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gocql/gocql" 6 | ) 7 | 8 | // ColumnDefinition defines a column to be added to a table 9 | type ColumnDefinition struct { 10 | // Name is a unique name for the column. 11 | Name string `json:"name" validate:"required"` 12 | 13 | // TypeDefinition defines the type of data allowed in the column 14 | TypeDefinition string `json:"typeDefinition" validate:"required,oneof=ascii text varchar tinyint smallint int bigint varint decimal float double date duration time timestamp uuid timeuuid blob boolean counter inet"` 15 | 16 | // Denotes that the column is shared by all rows of a partition 17 | Static bool `json:"static,omitempty"` 18 | } 19 | 20 | // toDbType gets a gocql data type for the provided string 21 | func toDbType(typeDefinition string) (gocql.TypeInfo, error) { 22 | var t gocql.Type 23 | switch typeDefinition { 24 | case gocql.TypeCustom.String(): 25 | t = gocql.TypeCustom 26 | case gocql.TypeAscii.String(): 27 | t = gocql.TypeAscii 28 | case gocql.TypeBigInt.String(): 29 | t = gocql.TypeBigInt 30 | case gocql.TypeBlob.String(): 31 | t = gocql.TypeBlob 32 | case gocql.TypeBoolean.String(): 33 | t = gocql.TypeBoolean 34 | case gocql.TypeCounter.String(): 35 | t = gocql.TypeCounter 36 | case gocql.TypeDecimal.String(): 37 | t = gocql.TypeDecimal 38 | case gocql.TypeDouble.String(): 39 | t = gocql.TypeDouble 40 | case gocql.TypeFloat.String(): 41 | t = gocql.TypeFloat 42 | case gocql.TypeInt.String(): 43 | t = gocql.TypeInt 44 | case gocql.TypeText.String(): 45 | t = gocql.TypeText 46 | case gocql.TypeTimestamp.String(): 47 | t = gocql.TypeTimestamp 48 | case gocql.TypeUUID.String(): 49 | t = gocql.TypeUUID 50 | case gocql.TypeVarchar.String(): 51 | t = gocql.TypeVarchar 52 | case gocql.TypeTimeUUID.String(): 53 | t = gocql.TypeTimeUUID 54 | case gocql.TypeInet.String(): 55 | t = gocql.TypeInet 56 | case gocql.TypeDate.String(): 57 | t = gocql.TypeDate 58 | case gocql.TypeDuration.String(): 59 | t = gocql.TypeDuration 60 | case gocql.TypeTime.String(): 61 | t = gocql.TypeTime 62 | case gocql.TypeSmallInt.String(): 63 | t = gocql.TypeSmallInt 64 | case gocql.TypeTinyInt.String(): 65 | t = gocql.TypeTinyInt 66 | case gocql.TypeVarint.String(): 67 | t = gocql.TypeVarint 68 | default: 69 | return nil, fmt.Errorf("type '%s' Not supported", typeDefinition) 70 | } 71 | 72 | return gocql.NewNativeType(0, t, ""), nil 73 | } 74 | 75 | // ToDbColumn gets a gocql column for the provided definition 76 | func ToDbColumn(definition ColumnDefinition) (*gocql.ColumnMetadata, error) { 77 | kind := gocql.ColumnRegular 78 | if definition.Static { 79 | kind = gocql.ColumnStatic 80 | } 81 | 82 | dbType, err := toDbType(definition.TypeDefinition) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | return &gocql.ColumnMetadata{ 88 | Name: definition.Name, 89 | Kind: kind, 90 | Type: dbType, 91 | }, nil 92 | } 93 | -------------------------------------------------------------------------------- /store.cql: -------------------------------------------------------------------------------- 1 | // GraphQL schema: 2 | // type BooksByAuthor { 3 | // authorId: String 4 | // firstname: String 5 | // lastname: String 6 | // title: String 7 | // } 8 | // 9 | // type BooksByTitle { 10 | // authorId: String 11 | // pages: Int 12 | // title: String 13 | // year: Int 14 | // } 15 | // 16 | // type Query { 17 | // booksByAuthor( 18 | // firstname: String! 19 | // lastname: String! 20 | // title: String 21 | // ): [BooksByAuthor] 22 | // booksByTitle(authorId: String, title: String!): [BooksByTitle] 23 | // } 24 | 25 | // Example GraphQL query: 26 | // query { 27 | // booksByAuthor(firstname: "Ariel", lastname: "Stein") { 28 | // title 29 | // firstname 30 | // lastname 31 | // authorId 32 | // } 33 | // booksByTitle(title: "Book 4") { 34 | // title 35 | // pages 36 | // year 37 | // } 38 | // } 39 | 40 | CREATE KEYSPACE IF NOT EXISTS store WITH replication = { 41 | 'class': 'NetworkTopologyStrategy', 'dc1': '1' 42 | }; 43 | 44 | DROP TABLE IF EXISTS store.books_by_title; 45 | DROP TABLE IF EXISTS store.books_by_author; 46 | 47 | CREATE TABLE store.books_by_title 48 | ( 49 | title text, 50 | author_id uuid, 51 | pages int, 52 | year int, 53 | PRIMARY KEY (title, author_id) 54 | ); 55 | 56 | INSERT INTO store.books_by_title (title, author_id, pages, year) 57 | VALUES ('Book 1', 1c3eb87a-9ce7-491e-9dd9-fb3c819875cf, 123, 1901); 58 | INSERT INTO store.books_by_title (title, author_id, pages, year) 59 | VALUES ('Book 2', 8fec26ff-09a0-4c23-9c8b-8bbf8e198f12, 456, 1902); 60 | INSERT INTO store.books_by_title (title, author_id, pages, year) 61 | VALUES ('Book 3', f8f5a9de-4bc6-4177-be64-87d0db7bf9be, 789, 2001); 62 | INSERT INTO store.books_by_title (title, author_id, pages, year) 63 | VALUES ('Book 4', 9e390783-f4c2-4a6e-ac82-818d35cada68, 101, 2002); 64 | INSERT INTO store.books_by_title (title, author_id, pages, year) 65 | VALUES ('Book 5', 9e390783-f4c2-4a6e-ac82-818d35cada68, 201, 2020); 66 | 67 | CREATE TABLE store.books_by_author 68 | ( 69 | firstname text, 70 | lastname text, 71 | title text, 72 | author_id uuid, 73 | PRIMARY KEY ((firstname, lastname), title) 74 | ); 75 | 76 | INSERT INTO store.books_by_author (firstname, lastname, title, author_id) 77 | VALUES ('Mike', 'Hoff', 'Book 1', 1c3eb87a-9ce7-491e-9dd9-fb3c819875cf); 78 | INSERT INTO store.books_by_author (firstname, lastname, title, author_id) 79 | VALUES ('Joe', 'Smith', 'Book 2', 8fec26ff-09a0-4c23-9c8b-8bbf8e198f12); 80 | INSERT INTO store.books_by_author (firstname, lastname, title, author_id) 81 | VALUES ('Adam', 'Samsung', 'Book 3', f8f5a9de-4bc6-4177-be64-87d0db7bf9be); 82 | INSERT INTO store.books_by_author (firstname, lastname, title, author_id) 83 | VALUES ('Ariel', 'Stein', 'Book 4', 9e390783-f4c2-4a6e-ac82-818d35cada68); 84 | INSERT INTO store.books_by_author (firstname, lastname, title, author_id) 85 | VALUES ('Ariel', 'Stein', 'Book 5', 9e390783-f4c2-4a6e-ac82-818d35cada68); 86 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "github.com/datastax/cassandra-data-apis/log" 6 | "github.com/gocql/gocql" 7 | "github.com/julienschmidt/httprouter" 8 | "net/http" 9 | "time" 10 | ) 11 | 12 | var SystemKeyspaces = []string{ 13 | "system", "system_auth", "system_distributed", "system_schema", "system_traces", "system_views", "system_virtual_schema", 14 | "dse_insights", "dse_insights_local", "dse_leases", "dse_perf", "dse_security", "dse_system", "dse_system_local", 15 | "solr_admin", "OpsCenter", "dse_analytics", "system_backups", "dsefs", 16 | } 17 | 18 | type Config interface { 19 | ExcludedKeyspaces() []string 20 | SchemaUpdateInterval() time.Duration 21 | Naming() NamingConventionFn 22 | UseUserOrRoleAuth() bool 23 | Logger() log.Logger 24 | RouterInfo() HttpRouterInfo 25 | } 26 | 27 | type UrlParamGetter func(*http.Request, string) string 28 | 29 | // UrlPattern determines how parameters are represented in the url 30 | // For example: "/graphql/:param1" (colon, default) or "/graphql/{param1}" (brackets) 31 | type UrlPattern int 32 | 33 | type HttpRouterInfo interface { 34 | UrlPattern() UrlPattern 35 | UrlParams() UrlParamGetter 36 | } 37 | 38 | const ( 39 | DefaultPageSize = 100 40 | DefaultConsistencyLevel = gocql.LocalQuorum 41 | DefaultSerialConsistencyLevel = gocql.Serial 42 | ) 43 | 44 | const ( 45 | UrlPatternColon UrlPattern = iota 46 | UrlPatternBrackets 47 | ) 48 | 49 | type routerInfo struct { 50 | urlPattern UrlPattern 51 | urlParamGetter UrlParamGetter 52 | } 53 | 54 | func (r *routerInfo) UrlPattern() UrlPattern { 55 | return r.urlPattern 56 | } 57 | 58 | func (r *routerInfo) UrlParams() UrlParamGetter { 59 | return r.urlParamGetter 60 | } 61 | 62 | func DefaultRouterInfo() HttpRouterInfo { 63 | return &routerInfo{ 64 | urlPattern: UrlPatternColon, 65 | urlParamGetter: func(r *http.Request, name string) string { 66 | params := httprouter.ParamsFromContext(r.Context()) 67 | return params.ByName(name) 68 | }, 69 | } 70 | } 71 | 72 | func (p UrlPattern) UrlPathFormat(format string, parameterNames ...string) string { 73 | return fmt.Sprintf(format, p.formatParameters(parameterNames)...) 74 | } 75 | 76 | func (p UrlPattern) formatParameters(names []string) []interface{} { 77 | switch p { 78 | case UrlPatternColon: 79 | return formatParametersWithColon(names) 80 | case UrlPatternBrackets: 81 | return formatParametersWithBrackets(names) 82 | default: 83 | panic("unexpected url pattern") 84 | } 85 | } 86 | 87 | func formatParametersWithColon(names []string) []interface{} { 88 | result := make([]interface{}, len(names)) 89 | for i, value := range names { 90 | result[i] = ":" + value 91 | } 92 | return result 93 | } 94 | 95 | func formatParametersWithBrackets(names []string) []interface{} { 96 | result := make([]interface{}, len(names)) 97 | for i, value := range names { 98 | result[i] = "{" + value + "}" 99 | } 100 | return result 101 | } 102 | -------------------------------------------------------------------------------- /db/table.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gocql/gocql" 6 | ) 7 | 8 | type CreateTableInfo struct { 9 | Keyspace string 10 | Table string 11 | PartitionKeys []*gocql.ColumnMetadata 12 | ClusteringKeys []*gocql.ColumnMetadata 13 | Values []*gocql.ColumnMetadata 14 | IfNotExists bool 15 | } 16 | 17 | type AlterTableAddInfo struct { 18 | Keyspace string 19 | Table string 20 | ToAdd []*gocql.ColumnMetadata 21 | } 22 | 23 | type AlterTableDropInfo struct { 24 | Keyspace string 25 | Table string 26 | ToDrop []string 27 | } 28 | 29 | type DropTableInfo struct { 30 | Keyspace string 31 | Table string 32 | IfExists bool 33 | } 34 | 35 | func toTypeString(info gocql.TypeInfo) string { 36 | if coll, ok := info.(gocql.CollectionType); ok { 37 | switch coll.Type() { 38 | case gocql.TypeList: 39 | fallthrough 40 | case gocql.TypeSet: 41 | return fmt.Sprintf("%s<%s>", 42 | coll.Type().String(), toTypeString(coll.Elem)) 43 | case gocql.TypeMap: 44 | return fmt.Sprintf("%s<%s, %s>", 45 | coll.Type().String(), toTypeString(coll.Key), toTypeString(coll.Elem)) 46 | } 47 | } 48 | return info.Type().String() 49 | } 50 | 51 | func (db *Db) CreateTable(info *CreateTableInfo, options *QueryOptions) error { 52 | columns := "" 53 | primaryKeys := "" 54 | clusteringOrder := "" 55 | 56 | for _, c := range info.PartitionKeys { 57 | columns += fmt.Sprintf(`"%s" %s, `, c.Name, toTypeString(c.Type)) 58 | primaryKeys += fmt.Sprintf(`, "%s"`, c.Name) 59 | } 60 | 61 | if info.ClusteringKeys != nil { 62 | primaryKeys = fmt.Sprintf("(%s)", primaryKeys[2:]) 63 | 64 | for _, c := range info.ClusteringKeys { 65 | columns += fmt.Sprintf(`"%s" %s, `, c.Name, toTypeString(c.Type)) 66 | primaryKeys += fmt.Sprintf(`, "%s"`, c.Name) 67 | order := c.ClusteringOrder 68 | if order == "" { 69 | order = "ASC" 70 | } 71 | clusteringOrder += fmt.Sprintf(`, "%s" %s`, c.Name, order) 72 | } 73 | } else { 74 | primaryKeys = primaryKeys[2:] 75 | } 76 | 77 | if info.Values != nil { 78 | for _, c := range info.Values { 79 | columns += fmt.Sprintf(`"%s" %s, `, c.Name, toTypeString(c.Type)) 80 | } 81 | } 82 | 83 | query := fmt.Sprintf(`CREATE TABLE %s"%s"."%s" (%sPRIMARY KEY (%s))`, 84 | ifNotExistsStr(info.IfNotExists), info.Keyspace, info.Table, columns, primaryKeys) 85 | 86 | if clusteringOrder != "" { 87 | query += fmt.Sprintf(" WITH CLUSTERING ORDER BY (%s)", clusteringOrder[2:]) 88 | } 89 | 90 | return db.session.ChangeSchema(query, options) 91 | } 92 | 93 | func (db *Db) AlterTableAdd(info *AlterTableAddInfo, options *QueryOptions) error { 94 | columns := "" 95 | for _, c := range info.ToAdd { 96 | columns += fmt.Sprintf(`, "%s" %s`, c.Name, toTypeString(c.Type)) 97 | } 98 | query := fmt.Sprintf(`ALTER TABLE "%s"."%s" ADD(%s)`, info.Keyspace, info.Table, columns[2:]) 99 | return db.session.ChangeSchema(query, options) 100 | } 101 | 102 | func (db *Db) AlterTableDrop(info *AlterTableDropInfo, options *QueryOptions) error { 103 | columns := "" 104 | for _, column := range info.ToDrop { 105 | columns += fmt.Sprintf(`, "%s"`, column) 106 | } 107 | query := fmt.Sprintf(`ALTER TABLE "%s"."%s" DROP %s`, info.Keyspace, info.Table, columns[2:]) 108 | return db.session.ChangeSchema(query, options) 109 | } 110 | 111 | func (db *Db) DropTable(info *DropTableInfo, options *QueryOptions) error { 112 | query := fmt.Sprintf(`DROP TABLE %s"%s"."%s"`, ifExistsStr(info.IfExists), info.Keyspace, info.Table) 113 | return db.session.ChangeSchema(query, options) 114 | } 115 | -------------------------------------------------------------------------------- /config/naming_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestNamingConventionToGraphQLField(t *testing.T) { 9 | nc := NewDefaultNaming(getKeyspaceNaming()) 10 | assert.NotNil(t, nc) 11 | assert.Equal(t, "a", nc.ToGraphQLField("tbl_a", "a")) 12 | assert.Equal(t, "bB", nc.ToGraphQLField("tbl_a", "b_b")) 13 | assert.Equal(t, "bB2", nc.ToGraphQLField("tbl_a", "b__b")) 14 | assert.Equal(t, "aB", nc.ToGraphQLField("tbl_A", "aB")) 15 | assert.Equal(t, "aB2", nc.ToGraphQLField("tbl_A", "a_b")) 16 | assert.Equal(t, "email", nc.ToGraphQLField("tbl_b", "email")) 17 | assert.Equal(t, "addressStreet", nc.ToGraphQLField("tbl_b", "address_street")) 18 | 19 | // Columns not found should be converted to camelCase anyway 20 | assert.Equal(t, "notFound", nc.ToGraphQLField("tbl_b", "not_found")) 21 | assert.Equal(t, "aColumn", nc.ToGraphQLField("tbl_not_found", "a_column")) 22 | } 23 | 24 | func TestNamingConventionToCQLColumn(t *testing.T) { 25 | nc := NewDefaultNaming(getKeyspaceNaming()) 26 | assert.NotNil(t, nc) 27 | assert.Equal(t, "a", nc.ToCQLColumn("tbl_a", "a")) 28 | assert.Equal(t, "b_b", nc.ToCQLColumn("tbl_a", "bB")) 29 | assert.Equal(t, "b__b", nc.ToCQLColumn("tbl_a", "bB2")) 30 | assert.Equal(t, "aB", nc.ToCQLColumn("tbl_A", "aB")) 31 | assert.Equal(t, "a_b", nc.ToCQLColumn("tbl_A", "aB2")) 32 | 33 | // Fields not found should be converted to snake_case anyway 34 | assert.Equal(t, "not_found", nc.ToCQLColumn("tbl_b", "notFound")) 35 | assert.Equal(t, "not_found", nc.ToCQLColumn("tbl_not_found", "notFound")) 36 | } 37 | 38 | func TestNamingConventionToGraphType(t *testing.T) { 39 | nc := NewDefaultNaming(getKeyspaceNaming()) 40 | assert.NotNil(t, nc) 41 | assert.Equal(t, "TblA2", nc.ToGraphQLType("tbl_a")) 42 | assert.Equal(t, "TblA", nc.ToGraphQLType("tbl_A")) 43 | assert.Equal(t, "TblB", nc.ToGraphQLType("tbl_b")) 44 | assert.Equal(t, "TblBFilter", nc.ToGraphQLType("tbl_b_filter")) 45 | 46 | // Fields not found should be converted to snake_case anyway 47 | assert.Equal(t, "TblNotFound", nc.ToGraphQLType("tbl_not_found")) 48 | } 49 | 50 | func TestNamingConventionToGraphQLTypeUnique(t *testing.T) { 51 | nc := NewDefaultNaming(getKeyspaceNaming()) 52 | assert.NotNil(t, nc) 53 | assert.Equal(t, "TblAFilter", nc.ToGraphQLTypeUnique("tbl_A", "filter")) 54 | assert.Equal(t, "TblAInput", nc.ToGraphQLTypeUnique("tbl_A", "input")) 55 | assert.Equal(t, "TblA2Filter", nc.ToGraphQLTypeUnique("tbl_a", "filter")) 56 | assert.Equal(t, "TblA2Input", nc.ToGraphQLTypeUnique("tbl_a", "input")) 57 | // There's a table that is represented as GraphQL Type "TblBFilter" 58 | assert.Equal(t, "TblBFilter2", nc.ToGraphQLTypeUnique("tbl_b", "filter")) 59 | assert.Equal(t, "TblBFilterFilter", nc.ToGraphQLTypeUnique("tbl_b_filter", "filter")) 60 | assert.Equal(t, "TblNotFoundSuffix", nc.ToGraphQLTypeUnique("tbl_not_found", "suffix")) 61 | } 62 | 63 | func TestNamingConventionWithReservedName(t *testing.T) { 64 | nc := NewDefaultNaming(getKeyspaceNaming()) 65 | assert.NotNil(t, nc) 66 | assert.Equal(t, "BigintCustom", nc.ToGraphQLType("bigint")) 67 | assert.Equal(t, "ConsistencyCustom", nc.ToGraphQLType("consistency")) 68 | } 69 | 70 | func getKeyspaceNaming() KeyspaceNamingInfo { 71 | infoMock := NewKeyspaceNamingInfoMock() 72 | infoMock.On("Tables").Return(map[string][]string{ 73 | "tbl_A": {"aB", "a_b", "first_name", "firstName"}, 74 | "tbl_a": {"a", "b_b", "b__b"}, 75 | "tbl_b": {"email", "address_street"}, 76 | "tbl_b_filter": {"col1", "col2"}, 77 | // Reserved names 78 | "bigint": {"col1"}, 79 | "consistency": {"col1"}, 80 | }) 81 | return infoMock 82 | } 83 | -------------------------------------------------------------------------------- /internal/testutil/simulacron.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | . "github.com/onsi/gomega" 8 | "net/http" 9 | "os" 10 | "os/exec" 11 | "strings" 12 | "time" 13 | ) 14 | 15 | const baseUrl = `http://localhost:8187` 16 | 17 | var cmd *exec.Cmd 18 | 19 | const SimulacronStartIp = "127.0.0.101" 20 | 21 | func StartSimulacron() { 22 | if cmd != nil { 23 | panic("Can not start simulacron multiple times") 24 | } 25 | fmt.Println("Starting simulacron") 26 | cmdStr := fmt.Sprintf("java -jar %s --ip %s", simulacronPath(), SimulacronStartIp) 27 | cmd = exec.Command("bash", "-c", cmdStr) 28 | var out bytes.Buffer 29 | cmd.Stdout = &out 30 | if err := cmd.Start(); err != nil { 31 | panic(err) 32 | } 33 | 34 | started := false 35 | for i := 0; i < 100; i++ { 36 | if strings.Contains(out.String(), "Started HTTP server interface") { 37 | started = true 38 | break 39 | } 40 | time.Sleep(100 * time.Millisecond) 41 | } 42 | 43 | if !started { 44 | panic("Simulacron failed to start") 45 | } 46 | } 47 | 48 | func simulacronPath() string { 49 | jarPath := os.Getenv("SIMULACRON_PATH") 50 | if jarPath == "" { 51 | panic("SIMULACRON_PATH env var is not set, it should point to the simulacron jar file") 52 | } 53 | return jarPath 54 | } 55 | 56 | func StopSimulacron() { 57 | fmt.Println("Stopping simulacron") 58 | if err := cmd.Process.Kill(); err != nil { 59 | fmt.Println("Failed to kill simulacron ", err) 60 | } 61 | } 62 | 63 | func CreateSimulacronCluster(dc1Length int, dc2Length int) { 64 | urlPath := `/cluster?data_centers=%d,%d&cassandra_version=3.11.6&name=test_cluster&activity_log=true&num_tokens=1` 65 | url := baseUrl + fmt.Sprintf(urlPath, dc1Length, dc2Length) 66 | _, err := http.Post(url, "application/json ", bytes.NewBufferString("")) 67 | Expect(err).NotTo(HaveOccurred()) 68 | } 69 | 70 | func GetQueryLogs(dcIndex int) ClusterQueryLogReport { 71 | urlPath := `/log/0/%d` 72 | url := baseUrl + fmt.Sprintf(urlPath, dcIndex) 73 | r, err := http.Get(url) 74 | Expect(err).NotTo(HaveOccurred()) 75 | var log ClusterQueryLogReport 76 | err = json.NewDecoder(r.Body).Decode(&log) 77 | Expect(err).NotTo(HaveOccurred()) 78 | return log 79 | } 80 | 81 | func CountLogMatches(nodeLogs []NodeQueryLogReport, query string) QueryMatches { 82 | matches := QueryMatches{} 83 | for _, node := range nodeLogs { 84 | for _, q := range node.Queries { 85 | message := q.Frame.Message 86 | if message.Type == "PREPARE" && message.Query == query { 87 | matches.Prepare++ 88 | } else if message.Type == "EXECUTE" { 89 | matches.Execute++ 90 | } 91 | } 92 | } 93 | return matches 94 | } 95 | 96 | type QueryMatches struct { 97 | Prepare int 98 | Execute int 99 | } 100 | 101 | type ClusterQueryLogReport struct { 102 | Id int64 `json:"id"` 103 | DataCenters []DataCenterQueryLogReport `json:"data_centers"` 104 | } 105 | 106 | type DataCenterQueryLogReport struct { 107 | Id int64 `json:"id"` 108 | Nodes []NodeQueryLogReport `json:"nodes"` 109 | } 110 | 111 | type NodeQueryLogReport struct { 112 | Id int64 `json:"id"` 113 | Queries []QueryLog `json:"queries"` 114 | } 115 | 116 | type QueryLog struct { 117 | Connection string `json:"connection"` 118 | Frame Frame `json:"frame"` 119 | } 120 | 121 | type Frame struct { 122 | ProtocolVersion int `json:"protocol_version"` 123 | Message Message `json:"message"` 124 | } 125 | 126 | type Message struct { 127 | Type string `json:"type"` 128 | Query string `json:"query"` 129 | } 130 | -------------------------------------------------------------------------------- /internal/testutil/schemas/killrvideo/killrvideo.go: -------------------------------------------------------------------------------- 1 | package killrvideo 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gocql/gocql" 6 | . "github.com/onsi/gomega" 7 | ) 8 | 9 | func InsertUserMutation(id interface{}, firstname interface{}, email interface{}, ifNotExists bool) string { 10 | query := `mutation { 11 | insertUsers(value:{userid:%s, firstname:%s, email:%s}%s) { 12 | applied 13 | value { 14 | userid 15 | firstname 16 | lastname 17 | email 18 | createdDate 19 | } 20 | } 21 | }` 22 | 23 | conditionalParameter := "" 24 | if ifNotExists { 25 | conditionalParameter = ", ifNotExists: true" 26 | } 27 | 28 | return fmt.Sprintf( 29 | query, asGraphQLString(id), asGraphQLString(firstname), asGraphQLString(email), conditionalParameter) 30 | } 31 | 32 | func UpdateUserMutation(id string, firstname string, email string, ifEmail string) string { 33 | query := `mutation { 34 | updateUsers(value:{userid:%s, firstname:%s, email:%s}%s) { 35 | applied 36 | value { 37 | userid 38 | firstname 39 | email 40 | } 41 | } 42 | }` 43 | 44 | conditionalParameter := "" 45 | if ifEmail != "" { 46 | conditionalParameter = fmt.Sprintf(`, ifCondition: { email: {eq: "%s"}}`, ifEmail) 47 | } 48 | 49 | return fmt.Sprintf( 50 | query, asGraphQLString(id), asGraphQLString(firstname), asGraphQLString(email), conditionalParameter) 51 | } 52 | 53 | func DeleteUserMutation(id string, ifNotName string) string { 54 | query := `mutation { 55 | deleteUsers(value:{userid:%s}%s) { 56 | applied 57 | value { 58 | userid 59 | firstname 60 | email 61 | } 62 | } 63 | }` 64 | 65 | conditionalParameter := "" 66 | if ifNotName != "" { 67 | conditionalParameter = fmt.Sprintf(`, ifCondition: { firstname: {notEq: "%s"}}`, ifNotName) 68 | } 69 | 70 | return fmt.Sprintf(query, asGraphQLString(id), conditionalParameter) 71 | } 72 | 73 | func SelectUserQuery(id string) string { 74 | query := `query { 75 | users(value:{userid:%s}) { 76 | pageState 77 | values { 78 | userid 79 | firstname 80 | lastname 81 | email 82 | createdDate 83 | } 84 | } 85 | }` 86 | 87 | return fmt.Sprintf(query, asGraphQLString(id)) 88 | } 89 | 90 | func SelectTagsByLetter(firstLetter string, pageSize int, pageState string) string { 91 | query := `{ 92 | tagsByLetter(value: {firstLetter: %s}, options: {pageSize: %d, pageState: %s}){ 93 | pageState 94 | values{ tag }} 95 | }` 96 | 97 | return fmt.Sprintf(query, asGraphQLString(firstLetter), pageSize, asGraphQLString(pageState)) 98 | } 99 | 100 | func SelectTagsByLetterNoWhereClause(pageState string) string { 101 | query := `{ 102 | tagsByLetter%s { 103 | pageState 104 | values{ tag }} 105 | }` 106 | 107 | params := "" 108 | if pageState != "" { 109 | params = fmt.Sprintf(`(options: {pageState: "%s"})`, pageState) 110 | } 111 | 112 | return fmt.Sprintf(query, params) 113 | } 114 | 115 | func CqlInsertTagByLetter(session *gocql.Session, tag string) { 116 | query := "INSERT INTO killrvideo.tags_by_letter (first_letter, tag) VALUES (?, ?)" 117 | err := session.Query(query, tag[0:1], tag).Exec() 118 | Expect(err).ToNot(HaveOccurred()) 119 | } 120 | 121 | func SelectCommentsByVideoGreaterThan(videoId string, startCommentId string) string { 122 | query := `query { 123 | commentsByVideoFilter(filter:{videoid:{eq: %s}, commentid: {gt: %s}}) { 124 | pageState 125 | values { 126 | videoid 127 | commentid 128 | comment 129 | userid 130 | } 131 | } 132 | }` 133 | 134 | return fmt.Sprintf(query, asGraphQLString(videoId), asGraphQLString(startCommentId)) 135 | } 136 | 137 | func asGraphQLString(value interface{}) string { 138 | if value == "" || value == nil { 139 | return "null" 140 | } 141 | return fmt.Sprintf(`"%s"`, value) 142 | } 143 | -------------------------------------------------------------------------------- /internal/testutil/rest/rest.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | e "github.com/datastax/cassandra-data-apis/rest/endpoint/v1" 8 | "github.com/datastax/cassandra-data-apis/rest/models" 9 | "github.com/datastax/cassandra-data-apis/types" 10 | "github.com/julienschmidt/httprouter" 11 | . "github.com/onsi/gomega" 12 | "io" 13 | "net/http" 14 | "net/http/httptest" 15 | "path" 16 | "reflect" 17 | "regexp" 18 | "strings" 19 | ) 20 | 21 | const Prefix = "/rest" 22 | 23 | func ExecuteGet(routes []types.Route, routeFormat string, responsePtr interface{}, values ...interface{}) int { 24 | return execute(http.MethodGet, routes, routeFormat, "", responsePtr, values...) 25 | } 26 | 27 | func ExecutePost( 28 | routes []types.Route, 29 | routeFormat string, 30 | requestBody string, 31 | responsePtr interface{}, 32 | values ...interface{}, 33 | ) int { 34 | return execute(http.MethodPost, routes, routeFormat, requestBody, responsePtr, values...) 35 | } 36 | 37 | func ExecutePut( 38 | routes []types.Route, 39 | routeFormat string, 40 | requestBody string, 41 | responsePtr interface{}, 42 | values ...interface{}, 43 | ) int { 44 | return execute(http.MethodPut, routes, routeFormat, requestBody, responsePtr, values...) 45 | } 46 | 47 | func ExecuteDelete( 48 | routes []types.Route, 49 | routeFormat string, 50 | values ...interface{}, 51 | ) int { 52 | return execute(http.MethodDelete, routes, routeFormat, "", nil, values...) 53 | } 54 | 55 | // ExecuteGetDataTypeJson performs a GET request and returns the json decoded value of the provided row cell 56 | func ExecuteGetDataTypeJsonValue(routes []types.Route, dataType, id string) interface{} { 57 | var response models.Rows 58 | ExecuteGet(routes, e.RowSinglePathFormat, &response, "datatypes", "scalars", id) 59 | Expect(response.Rows).To(HaveLen(1)) 60 | return response.Rows[0][dataType+"_col"] 61 | } 62 | 63 | func execute( 64 | method string, 65 | routes []types.Route, 66 | routeFormat string, 67 | requestBody string, 68 | responsePtr interface{}, 69 | values ...interface{}, 70 | ) int { 71 | rv := reflect.ValueOf(responsePtr) 72 | if responsePtr != nil && rv.Kind() != reflect.Ptr { 73 | panic("Provided value should be a pointer or nil") 74 | } 75 | 76 | targetPath := path.Join(Prefix, fmt.Sprintf(routeFormat, values...)) 77 | var body io.Reader = nil 78 | if requestBody != "" { 79 | body = bytes.NewBuffer([]byte(requestBody)) 80 | } 81 | 82 | r, _ := http.NewRequest(method, targetPath, body) 83 | if body != nil { 84 | r.Header.Set("Content-Type", "application/json") 85 | } 86 | 87 | w := httptest.NewRecorder() 88 | route := lookupRoute(routes, method, routeFormat) 89 | 90 | // Use default router for params to be populated 91 | router := httprouter.New() 92 | router.Handler(method, route.Pattern, route.Handler) 93 | router.ServeHTTP(w, r) 94 | 95 | if w.Code < http.StatusOK || w.Code > http.StatusIMUsed { 96 | // Not in the 2xx range 97 | if responsePtr == nil { 98 | return w.Code 99 | } 100 | _, ok := responsePtr.(*models.ModelError) 101 | if !ok { 102 | panic(fmt.Sprintf("unexpected http error %d: %s", w.Code, w.Body)) 103 | } 104 | } 105 | 106 | if w.Code != http.StatusNoContent { 107 | bodyString := w.Body.String() 108 | err := json.NewDecoder(bytes.NewBufferString(bodyString)).Decode(responsePtr) 109 | Expect(err).ToNot(HaveOccurred(), 110 | fmt.Sprintf("Error decoding response with code %d and body: %s", w.Code, bodyString)) 111 | } 112 | 113 | return w.Code 114 | } 115 | 116 | func lookupRoute(routes []types.Route, method, format string) types.Route { 117 | // Word tokens for parameters 118 | regexStr := strings.Replace(format, `%s`, `[\w:{}]+`, -1) 119 | // End of the string 120 | regexStr += `$` 121 | 122 | re := regexp.MustCompile(regexStr) 123 | for _, route := range routes { 124 | if re.MatchString(route.Pattern) && route.Method == method { 125 | return route 126 | } 127 | } 128 | 129 | panic("Route not found") 130 | } 131 | -------------------------------------------------------------------------------- /graphql/scalars.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "github.com/datastax/cassandra-data-apis/types" 5 | "github.com/graphql-go/graphql" 6 | "github.com/graphql-go/graphql/language/ast" 7 | "strconv" 8 | ) 9 | 10 | var uuid = newStringNativeScalar("Uuid", "The `Uuid` scalar type represents a CQL uuid as a string.") 11 | 12 | var timeuuid = newStringNativeScalar("TimeUuid", "The `TimeUuid` scalar type represents a CQL timeuuid as a string.") 13 | 14 | var ip = newStringNativeScalar("Inet", "The `Inet` scalar type represents a CQL inet as a string.") 15 | 16 | var bigint = newStringNativeScalar( 17 | "BigInt", "The `BigInt` scalar type represents a CQL bigint (64-bit signed integer) as a string.") 18 | 19 | var counter = newStringNativeScalar( 20 | "Counter", "The `Counter` scalar type represents a CQL counter (64-bit signed integer) as a string.") 21 | 22 | var ascii = newStringNativeScalar( 23 | "Ascii", "The `Ascii` scalar type represents CQL ascii character values as a string.") 24 | 25 | var decimal = newStringScalar( 26 | "Decimal", "The `Decimal` scalar type represents a CQL decimal as a string.", 27 | types.StringerToString, errToNilDeserializer(deserializerWithErrorFn(types.StringToDecimal))) 28 | 29 | var varint = newStringScalar( 30 | "Varint", "The `Varint` scalar type represents a CQL varint as a string.", 31 | types.StringerToString, errToNilDeserializer(deserializerWithErrorFn(types.StringToBigInt))) 32 | 33 | var float32Scalar = graphql.NewScalar(graphql.ScalarConfig{ 34 | Name: "Float32", 35 | Description: "The `Float32` scalar type represents a CQL float (single-precision floating point values).", 36 | Serialize: identityFn, 37 | ParseValue: deserializeFloat32, 38 | ParseLiteral: func(valueAST ast.Value) interface{} { 39 | switch valueAST := valueAST.(type) { 40 | case *ast.FloatValue: 41 | return deserializeFloat32(valueAST.Value) 42 | case *ast.IntValue: 43 | return deserializeFloat32(valueAST.Value) 44 | } 45 | return nil 46 | }, 47 | }) 48 | 49 | var blob = newStringScalar( 50 | "Blob", "The `Blob` scalar type represents a CQL blob as a base64 encoded byte array.", 51 | types.ByteArrayToBase64String, errToNilDeserializer(types.Base64StringToByteArray)) 52 | 53 | var timestamp = newStringScalar( 54 | "Timestamp", "The `Timestamp` scalar type represents a DateTime."+ 55 | " The Timestamp is serialized as a RFC 3339 quoted string", 56 | types.TimeAsString, errToNilDeserializer(deserializerWithErrorFn(types.StringToTime))) 57 | 58 | var localTime = newStringScalar( 59 | "Time", "The `Time` scalar type represents a local time."+ 60 | " Values are represented as strings, such as 13:30:54.234..", 61 | types.DurationToCqlFormattedString, errToNilDeserializer(types.CqlFormattedStringToDuration)) 62 | 63 | // newStringNativeScalar Creates an string-based scalar with custom serialization functions 64 | func newStringScalar( 65 | name string, description string, serializeFn graphql.SerializeFn, deserializeFn graphql.ParseValueFn, 66 | ) *graphql.Scalar { 67 | return graphql.NewScalar(graphql.ScalarConfig{ 68 | Name: name, 69 | Description: description, 70 | Serialize: serializeFn, 71 | ParseValue: deserializeFn, 72 | ParseLiteral: parseLiteralFromStringHandler(deserializeFn), 73 | }) 74 | } 75 | 76 | // newStringNativeScalar Creates an string-based scalar that has native representation in gocql (no parsing or needed) 77 | func newStringNativeScalar(name string, description string) *graphql.Scalar { 78 | return newStringScalar(name, description, identityFn, identityFn) 79 | } 80 | 81 | func identityFn(value interface{}) interface{} { 82 | return value 83 | } 84 | 85 | type deserializerWithErrorFn func(interface{}) (interface{}, error) 86 | 87 | func errToNilDeserializer(fn deserializerWithErrorFn) graphql.ParseValueFn { 88 | return func(value interface{}) interface{} { 89 | result, err := fn(value) 90 | if err != nil { 91 | return nil 92 | } 93 | return result 94 | } 95 | } 96 | 97 | func parseLiteralFromStringHandler(parser graphql.ParseValueFn) graphql.ParseLiteralFn { 98 | return func(valueAST ast.Value) interface{} { 99 | switch valueAST := valueAST.(type) { 100 | case *ast.StringValue: 101 | return parser(valueAST.Value) 102 | } 103 | return nil 104 | } 105 | } 106 | 107 | func deserializeFloat32(value interface{}) interface{} { 108 | switch value := value.(type) { 109 | case float64: 110 | return float32(value) 111 | case string: 112 | float64Value, err := strconv.ParseFloat(value, 32) 113 | if err != nil { 114 | return nil 115 | } 116 | return float32(float64Value) 117 | default: 118 | return value 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /internal/testutil/schemas/util.go: -------------------------------------------------------------------------------- 1 | package schemas 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "github.com/datastax/cassandra-data-apis/graphql" 8 | "github.com/datastax/cassandra-data-apis/internal/testutil" 9 | "github.com/datastax/cassandra-data-apis/types" 10 | "github.com/gocql/gocql" 11 | "github.com/iancoleman/strcase" 12 | . "github.com/onsi/gomega" 13 | "net/http" 14 | "net/http/httptest" 15 | "path" 16 | ) 17 | 18 | type ResponseBody struct { 19 | Data map[string]interface{} `json:"data"` 20 | Errors []ErrorEntry `json:"errors"` 21 | } 22 | 23 | type ErrorEntry struct { 24 | Message string `json:"message"` 25 | Path []string `json:"path"` 26 | Locations []struct { 27 | Line int `json:"line"` 28 | Column int `json:"column"` 29 | } `json:"locations"` 30 | } 31 | 32 | const GraphQLTypesQuery = `{ 33 | __schema { 34 | types { 35 | name 36 | description 37 | } 38 | } 39 | }` 40 | 41 | const ( 42 | postIndex = 1 43 | host = "127.0.0.1" 44 | ) 45 | 46 | func DecodeResponse(buffer *bytes.Buffer) ResponseBody { 47 | var response ResponseBody 48 | err := json.NewDecoder(buffer).Decode(&response) 49 | Expect(err).ToNot(HaveOccurred()) 50 | return response 51 | } 52 | 53 | func DecodeData(buffer *bytes.Buffer, key string) map[string]interface{} { 54 | response := DecodeResponse(buffer) 55 | Expect(response.Errors).To(HaveLen(0)) 56 | value, found := response.Data[key] 57 | if !found { 58 | panic(fmt.Sprintf("%s key not in response: %v", key, response)) 59 | } 60 | return value.(map[string]interface{}) 61 | } 62 | 63 | func DecodeDataAsSliceOfMaps(buffer *bytes.Buffer, key string, property string) []map[string]interface{} { 64 | arr := DecodeData(buffer, key)[property].([]interface{}) 65 | result := make([]map[string]interface{}, 0, len(arr)) 66 | for _, item := range arr { 67 | result = append(result, item.(map[string]interface{})) 68 | } 69 | return result 70 | } 71 | 72 | func NewResponseBody(operationName string, elementMap map[string]interface{}) ResponseBody { 73 | return ResponseBody{ 74 | Data: map[string]interface{}{ 75 | operationName: elementMap, 76 | }, 77 | } 78 | } 79 | 80 | func GetTypeNamesByTable(tableName string) []string { 81 | baseName := strcase.ToCamel(tableName) 82 | return []string{ 83 | baseName + "Input", 84 | baseName + "FilterInput", 85 | baseName, 86 | baseName + "Result", 87 | baseName + "Order", 88 | baseName + "MutationResult", 89 | } 90 | } 91 | 92 | func NewUuid() string { 93 | uuid, err := gocql.RandomUUID() 94 | testutil.PanicIfError(err) 95 | return uuid.String() 96 | } 97 | 98 | func ExecutePost(routes []types.Route, target string, body string) *bytes.Buffer { 99 | b, err := json.Marshal(graphql.RequestBody{Query: body}) 100 | Expect(err).ToNot(HaveOccurred()) 101 | targetUrl := fmt.Sprintf("http://%s", path.Join(host, target)) 102 | r := httptest.NewRequest(http.MethodPost, targetUrl, bytes.NewReader(b)) 103 | w := httptest.NewRecorder() 104 | routes[postIndex].Handler.ServeHTTP(w, r) 105 | Expect(w.Code).To(Equal(http.StatusOK)) 106 | return w.Body 107 | } 108 | 109 | func ExecutePostWithVariables(routes []types.Route, target string, body string, variables map[string]interface{}) *bytes.Buffer { 110 | b, err := json.Marshal(graphql.RequestBody{Query: body, Variables: variables}) 111 | Expect(err).ToNot(HaveOccurred()) 112 | targetUrl := fmt.Sprintf("http://%s", path.Join(host, target)) 113 | r := httptest.NewRequest(http.MethodPost, targetUrl, bytes.NewReader(b)) 114 | w := httptest.NewRecorder() 115 | routes[postIndex].Handler.ServeHTTP(w, r) 116 | Expect(w.Code).To(Equal(http.StatusOK)) 117 | return w.Body 118 | } 119 | 120 | func ExpectQueryToReturnError(routes []types.Route, query string, expectedMessage string) { 121 | b, err := json.Marshal(graphql.RequestBody{Query: query}) 122 | Expect(err).ToNot(HaveOccurred()) 123 | targetUrl := fmt.Sprintf("http://%s", path.Join(host, "/graphql")) 124 | r := httptest.NewRequest(http.MethodPost, targetUrl, bytes.NewReader(b)) 125 | w := httptest.NewRecorder() 126 | routes[postIndex].Handler.ServeHTTP(w, r) 127 | // GraphQL spec defines the error as a field and HTTP status code should still be 200 128 | // http://spec.graphql.org/June2018/#sec-Errors 129 | Expect(w.Code).To(Equal(http.StatusOK)) 130 | response := DecodeResponse(w.Body) 131 | Expect(response.Data).To(HaveLen(0)) 132 | ExpectError(response, expectedMessage) 133 | } 134 | 135 | func ExpectError(response ResponseBody, expectedMessage string) { 136 | Expect(response.Errors).To(HaveLen(1)) 137 | Expect(response.Errors[0].Message).To(ContainSubstring(expectedMessage)) 138 | } 139 | -------------------------------------------------------------------------------- /db/type_mapping.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gocql/gocql" 6 | "gopkg.in/inf.v0" 7 | "math/big" 8 | "reflect" 9 | "time" 10 | ) 11 | 12 | var typeForCqlType = map[gocql.Type]reflect.Type{ 13 | gocql.TypeFloat: reflect.TypeOf(float32(0)), 14 | gocql.TypeDouble: reflect.TypeOf(float64(0)), 15 | gocql.TypeInt: reflect.TypeOf(0), 16 | gocql.TypeSmallInt: reflect.TypeOf(int16(0)), 17 | gocql.TypeTinyInt: reflect.TypeOf(int8(0)), 18 | gocql.TypeBigInt: reflect.TypeOf("0"), 19 | gocql.TypeCounter: reflect.TypeOf("0"), 20 | gocql.TypeDecimal: reflect.TypeOf(new(inf.Dec)), 21 | gocql.TypeVarint: reflect.TypeOf(new(big.Int)), 22 | gocql.TypeText: reflect.TypeOf("0"), 23 | gocql.TypeVarchar: reflect.TypeOf("0"), 24 | gocql.TypeAscii: reflect.TypeOf("0"), 25 | gocql.TypeBoolean: reflect.TypeOf(false), 26 | gocql.TypeInet: reflect.TypeOf("0"), 27 | gocql.TypeUUID: reflect.TypeOf("0"), 28 | gocql.TypeTimeUUID: reflect.TypeOf("0"), 29 | gocql.TypeTimestamp: reflect.TypeOf(new(time.Time)), 30 | gocql.TypeBlob: reflect.TypeOf(new([]byte)), 31 | gocql.TypeTime: reflect.TypeOf(new(time.Duration)), 32 | } 33 | 34 | func mapScan(scanner gocql.Scanner, columns []gocql.ColumnInfo) (map[string]interface{}, error) { 35 | values := make([]interface{}, len(columns)) 36 | 37 | for i := range values { 38 | typeInfo := columns[i].TypeInfo 39 | allocated := allocateForType(typeInfo) 40 | if allocated == nil { 41 | return nil, fmt.Errorf("Support for CQL type not found: %s", typeInfo.Type().String()) 42 | } 43 | values[i] = allocated 44 | } 45 | 46 | if err := scanner.Scan(values...); err != nil { 47 | return nil, err 48 | } 49 | 50 | mapped := make(map[string]interface{}, len(values)) 51 | for i, column := range columns { 52 | value := values[i] 53 | switch column.TypeInfo.Type() { 54 | case gocql.TypeVarchar, gocql.TypeAscii, gocql.TypeInet, gocql.TypeText, 55 | gocql.TypeBigInt, gocql.TypeInt, gocql.TypeSmallInt, gocql.TypeTinyInt, 56 | gocql.TypeCounter, gocql.TypeBoolean, 57 | gocql.TypeTimeUUID, gocql.TypeUUID, 58 | gocql.TypeFloat, gocql.TypeDouble, 59 | gocql.TypeDecimal, gocql.TypeVarint, gocql.TypeTimestamp, gocql.TypeBlob, gocql.TypeTime: 60 | value = reflect.Indirect(reflect.ValueOf(value)).Interface() 61 | } 62 | 63 | mapped[column.Name] = value 64 | } 65 | 66 | return mapped, nil 67 | } 68 | 69 | func allocateForType(info gocql.TypeInfo) interface{} { 70 | 71 | a := time.Duration(123) 72 | a.Nanoseconds() 73 | switch info.Type() { 74 | case gocql.TypeVarchar, gocql.TypeAscii, gocql.TypeInet, gocql.TypeText: 75 | return new(*string) 76 | case gocql.TypeBigInt, gocql.TypeCounter: 77 | // We try to use types that have graphql/json representation 78 | return new(*string) 79 | case gocql.TypeBoolean: 80 | return new(*bool) 81 | case gocql.TypeFloat: 82 | // Mapped to a json Number 83 | return new(*float32) 84 | case gocql.TypeDouble: 85 | // Mapped to a json Number 86 | return new(*float64) 87 | case gocql.TypeInt: 88 | return new(*int) 89 | case gocql.TypeSmallInt: 90 | return new(*int16) 91 | case gocql.TypeTinyInt: 92 | return new(*int8) 93 | case gocql.TypeDecimal: 94 | return new(*inf.Dec) 95 | case gocql.TypeVarint: 96 | return new(*big.Int) 97 | case gocql.TypeTimeUUID, gocql.TypeUUID: 98 | // Mapped to a json string 99 | return new(*string) 100 | case gocql.TypeTimestamp: 101 | return new(*time.Time) 102 | case gocql.TypeBlob: 103 | return new(*[]byte) 104 | case gocql.TypeTime: 105 | return new(*time.Duration) 106 | case gocql.TypeList, gocql.TypeSet: 107 | subTypeInfo, ok := info.(gocql.CollectionType) 108 | if !ok { 109 | return nil 110 | } 111 | 112 | var subType reflect.Type 113 | if subType = getSubType(subTypeInfo.Elem); subType == nil { 114 | return nil 115 | } 116 | 117 | return reflect.New(reflect.SliceOf(subType)).Interface() 118 | case gocql.TypeMap: 119 | subTypeInfo, ok := info.(gocql.CollectionType) 120 | if !ok { 121 | return nil 122 | } 123 | 124 | var keyType reflect.Type 125 | var valueType reflect.Type 126 | if keyType = getSubType(subTypeInfo.Key); keyType == nil { 127 | return nil 128 | } 129 | if valueType = getSubType(subTypeInfo.Elem); valueType == nil { 130 | return nil 131 | } 132 | 133 | return reflect.New(reflect.MapOf(keyType, valueType)).Interface() 134 | default: 135 | return nil 136 | } 137 | } 138 | 139 | func getSubType(info gocql.TypeInfo) reflect.Type { 140 | t, typeFound := typeForCqlType[info.Type()] 141 | 142 | if !typeFound { 143 | // Create the subtype by allocating it 144 | allocated := allocateForType(info) 145 | 146 | if allocated == nil { 147 | return nil 148 | } 149 | 150 | t = reflect.ValueOf(allocated).Elem().Type() 151 | } 152 | 153 | return t 154 | } 155 | -------------------------------------------------------------------------------- /db/mocks.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "github.com/gocql/gocql" 5 | "github.com/stretchr/testify/mock" 6 | "sort" 7 | ) 8 | 9 | type SessionMock struct { 10 | mock.Mock 11 | } 12 | 13 | func (o *SessionMock) Execute(query string, options *QueryOptions, values ...interface{}) error { 14 | args := o.Called(query, options, values) 15 | return args.Error(0) 16 | } 17 | 18 | func (o *SessionMock) ExecuteIter(query string, options *QueryOptions, values ...interface{}) (ResultSet, error) { 19 | args := o.Called(query, options, values) 20 | return args.Get(0).(ResultSet), args.Error(1) 21 | } 22 | 23 | func (o *SessionMock) ChangeSchema(query string, options *QueryOptions) error { 24 | args := o.Called(query, options) 25 | return args.Error(0) 26 | } 27 | 28 | func (o *SessionMock) KeyspaceMetadata(keyspaceName string) (*gocql.KeyspaceMetadata, error) { 29 | args := o.Called(keyspaceName) 30 | return args.Get(0).(*gocql.KeyspaceMetadata), args.Error(1) 31 | } 32 | 33 | type ResultMock struct { 34 | mock.Mock 35 | } 36 | 37 | func (o ResultMock) PageState() []byte { 38 | return o.Called().Get(0).([]byte) 39 | } 40 | 41 | func (o ResultMock) Values() []map[string]interface{} { 42 | args := o.Called() 43 | return args.Get(0).([]map[string]interface{}) 44 | } 45 | 46 | var BooksColumnsMock = []*gocql.ColumnMetadata{ 47 | &gocql.ColumnMetadata{ 48 | Name: "title", 49 | Kind: gocql.ColumnPartitionKey, 50 | Type: gocql.NewNativeType(0, gocql.TypeText, ""), 51 | }, 52 | &gocql.ColumnMetadata{ 53 | Name: "pages", 54 | Kind: gocql.ColumnRegular, 55 | Type: gocql.NewNativeType(0, gocql.TypeInt, ""), 56 | }, 57 | &gocql.ColumnMetadata{ 58 | Name: "first_name", 59 | Kind: gocql.ColumnRegular, 60 | Type: gocql.NewNativeType(0, gocql.TypeText, ""), 61 | }, 62 | &gocql.ColumnMetadata{ 63 | Name: "last_name", 64 | Kind: gocql.ColumnRegular, 65 | Type: gocql.NewNativeType(0, gocql.TypeText, ""), 66 | }, 67 | } 68 | 69 | func NewKeyspaceMock(ksName string, tables map[string][]*gocql.ColumnMetadata) *gocql.KeyspaceMetadata { 70 | tableMap := map[string]*gocql.TableMetadata{} 71 | 72 | for tableName, columns := range tables { 73 | tableEntry := &gocql.TableMetadata{ 74 | Keyspace: ksName, 75 | Name: tableName, 76 | Columns: map[string]*gocql.ColumnMetadata{}, 77 | } 78 | for i, column := range columns { 79 | column.Keyspace = ksName 80 | column.Table = tableName 81 | column.ComponentIndex = i 82 | tableEntry.Columns[column.Name] = column 83 | } 84 | tableEntry.PartitionKey = createKey(tableEntry.Columns, gocql.ColumnPartitionKey) 85 | tableEntry.ClusteringColumns = createKey(tableEntry.Columns, gocql.ColumnClusteringKey) 86 | tableMap[tableName] = tableEntry 87 | } 88 | 89 | return &gocql.KeyspaceMetadata{ 90 | Name: ksName, 91 | DurableWrites: true, 92 | StrategyClass: "NetworkTopologyStrategy", 93 | StrategyOptions: map[string]interface{}{ 94 | "dc1": "3", 95 | }, 96 | Tables: tableMap, 97 | } 98 | } 99 | 100 | func NewSessionMock() *SessionMock { 101 | return &SessionMock{} 102 | } 103 | 104 | func (o *SessionMock) Default() *SessionMock { 105 | o.SetSchemaVersion("a78bc282-aff7-4c2a-8f23-4ce3584adbb0") 106 | o.AddKeyspace(NewKeyspaceMock( 107 | "store", map[string][]*gocql.ColumnMetadata{ 108 | "books": BooksColumnsMock, 109 | })) 110 | o.AddViews(nil) 111 | return o 112 | } 113 | 114 | func (o *SessionMock) SetSchemaVersion(version string) *mock.Call { 115 | schemaVersionResultMock := &ResultMock{} 116 | schemaVersionResultMock. 117 | On("Values").Return([]map[string]interface{}{ 118 | map[string]interface{}{"schema_version": &version}, 119 | }, nil) 120 | 121 | return o.On("ExecuteIter", "SELECT schema_version FROM system.local", mock.Anything, mock.Anything). 122 | Return(schemaVersionResultMock, nil) 123 | } 124 | 125 | func (o *SessionMock) AddViews(views []string) *mock.Call { 126 | values := make([]map[string]interface{}, 0, len(views)) 127 | for _, value := range views { 128 | values = append(values, map[string]interface{}{"view_name": &value}) 129 | } 130 | viewsResultMock := &ResultMock{} 131 | viewsResultMock. 132 | On("Values").Return(values, nil) 133 | 134 | return o.On("ExecuteIter", 135 | "SELECT view_name FROM system_schema.views WHERE keyspace_name = ?", 136 | mock.Anything, mock.Anything). 137 | Return(viewsResultMock, nil) 138 | } 139 | 140 | func (o *SessionMock) AddKeyspace(keyspace *gocql.KeyspaceMetadata) *mock.Call { 141 | return o.On("KeyspaceMetadata", keyspace.Name).Return(keyspace, nil) 142 | } 143 | 144 | func createKey(columns map[string]*gocql.ColumnMetadata, kind gocql.ColumnKind) []*gocql.ColumnMetadata { 145 | key := make([]*gocql.ColumnMetadata, 0) 146 | for _, column := range columns { 147 | if column.Kind == kind { 148 | key = append(key, column) 149 | } 150 | } 151 | sort.Slice(key, func(i, j int) bool { 152 | return key[i].ComponentIndex < key[j].ComponentIndex 153 | }) 154 | return key 155 | } 156 | -------------------------------------------------------------------------------- /endpoint/endpoint.go: -------------------------------------------------------------------------------- 1 | package endpoint 2 | 3 | import ( 4 | "github.com/datastax/cassandra-data-apis/config" 5 | "github.com/datastax/cassandra-data-apis/db" 6 | "github.com/datastax/cassandra-data-apis/graphql" 7 | "github.com/datastax/cassandra-data-apis/log" 8 | "github.com/datastax/cassandra-data-apis/rest" 9 | "github.com/datastax/cassandra-data-apis/types" 10 | "go.uber.org/zap" 11 | "time" 12 | ) 13 | 14 | const DefaultSchemaUpdateDuration = 10 * time.Second 15 | 16 | type DataEndpointConfig struct { 17 | dbConfig db.Config 18 | dbHosts []string 19 | ksExcluded []string 20 | updateInterval time.Duration 21 | naming config.NamingConventionFn 22 | useUserOrRoleAuth bool 23 | logger log.Logger 24 | routerInfo config.HttpRouterInfo 25 | } 26 | 27 | func (cfg DataEndpointConfig) ExcludedKeyspaces() []string { 28 | return cfg.ksExcluded 29 | } 30 | 31 | func (cfg DataEndpointConfig) SchemaUpdateInterval() time.Duration { 32 | return cfg.updateInterval 33 | } 34 | 35 | func (cfg DataEndpointConfig) Naming() config.NamingConventionFn { 36 | return cfg.naming 37 | } 38 | 39 | func (cfg DataEndpointConfig) UseUserOrRoleAuth() bool { 40 | return cfg.useUserOrRoleAuth 41 | } 42 | 43 | func (cfg DataEndpointConfig) DbConfig() db.Config { 44 | return cfg.dbConfig 45 | } 46 | 47 | func (cfg DataEndpointConfig) Logger() log.Logger { 48 | return cfg.logger 49 | } 50 | 51 | func (cfg DataEndpointConfig) RouterInfo() config.HttpRouterInfo { 52 | return cfg.routerInfo 53 | } 54 | 55 | func (cfg *DataEndpointConfig) WithExcludedKeyspaces(ksExcluded []string) *DataEndpointConfig { 56 | cfg.ksExcluded = ksExcluded 57 | return cfg 58 | } 59 | 60 | func (cfg *DataEndpointConfig) WithSchemaUpdateInterval(updateInterval time.Duration) *DataEndpointConfig { 61 | cfg.updateInterval = updateInterval 62 | return cfg 63 | } 64 | 65 | func (cfg *DataEndpointConfig) WithNaming(naming config.NamingConventionFn) *DataEndpointConfig { 66 | cfg.naming = naming 67 | return cfg 68 | } 69 | 70 | func (cfg *DataEndpointConfig) WithUseUserOrRoleAuth(useUserOrRowAuth bool) *DataEndpointConfig { 71 | cfg.useUserOrRoleAuth = useUserOrRowAuth 72 | return cfg 73 | } 74 | 75 | func (cfg *DataEndpointConfig) WithDbConfig(dbConfig db.Config) *DataEndpointConfig { 76 | cfg.dbConfig = dbConfig 77 | return cfg 78 | } 79 | 80 | // WithRouterInfo sets the http router information to be used for url parameters 81 | func (cfg *DataEndpointConfig) WithRouterInfo(routerInfo config.HttpRouterInfo) *DataEndpointConfig { 82 | cfg.routerInfo = routerInfo 83 | return cfg 84 | } 85 | 86 | func (cfg DataEndpointConfig) NewEndpoint() (*DataEndpoint, error) { 87 | dbClient, err := db.NewDb(cfg.dbConfig, cfg.dbHosts...) 88 | if err != nil { 89 | return nil, err 90 | } 91 | return cfg.newEndpointWithDb(dbClient), nil 92 | } 93 | 94 | func (cfg DataEndpointConfig) newEndpointWithDb(dbClient *db.Db) *DataEndpoint { 95 | return &DataEndpoint{ 96 | graphQLRouteGen: graphql.NewRouteGenerator(dbClient, cfg), 97 | restRouteGen: rest.NewRouteGenerator(dbClient, cfg), 98 | } 99 | } 100 | 101 | type DataEndpoint struct { 102 | graphQLRouteGen *graphql.RouteGenerator 103 | restRouteGen *rest.RouteGenerator 104 | } 105 | 106 | func NewEndpointConfig(hosts ...string) (*DataEndpointConfig, error) { 107 | logger, err := zap.NewProduction() 108 | if err != nil { 109 | return nil, err 110 | } 111 | return NewEndpointConfigWithLogger(log.NewZapLogger(logger), hosts...), nil 112 | } 113 | 114 | func NewEndpointConfigWithLogger(logger log.Logger, hosts ...string) *DataEndpointConfig { 115 | return &DataEndpointConfig{ 116 | dbHosts: hosts, 117 | updateInterval: DefaultSchemaUpdateDuration, 118 | naming: config.NewDefaultNaming, 119 | logger: logger, 120 | routerInfo: config.DefaultRouterInfo(), 121 | } 122 | } 123 | 124 | func (e *DataEndpoint) RoutesGraphQL(pattern string) ([]types.Route, error) { 125 | return e.graphQLRouteGen.Routes(pattern, "") 126 | } 127 | 128 | func (e *DataEndpoint) RoutesKeyspaceGraphQL(pattern string, ksName string) ([]types.Route, error) { 129 | return e.graphQLRouteGen.Routes(pattern, ksName) 130 | } 131 | 132 | func (e *DataEndpoint) RoutesSchemaManagementGraphQL(pattern string, ops config.SchemaOperations) ([]types.Route, error) { 133 | return e.graphQLRouteGen.RoutesSchemaManagement(pattern, "", ops) 134 | } 135 | 136 | func (e *DataEndpoint) RoutesSchemaManagementKeyspaceGraphQL(pattern string, ksName string, ops config.SchemaOperations) ([]types.Route, error) { 137 | return e.graphQLRouteGen.RoutesSchemaManagement(pattern, ksName, ops) 138 | } 139 | 140 | // Keyspaces gets a slice of keyspace names that are considered by the endpoint when used in multi-keyspace mode. 141 | func (e *DataEndpoint) Keyspaces() ([]string, error) { 142 | return e.graphQLRouteGen.Keyspaces() 143 | } 144 | 145 | func (e *DataEndpoint) RoutesRest(pattern string, operations config.SchemaOperations, singleKs string) []types.Route { 146 | return e.restRouteGen.Routes(pattern, operations, singleKs) 147 | } 148 | -------------------------------------------------------------------------------- /internal/testutil/testutil.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "fmt" 5 | "github.com/datastax/cassandra-data-apis/log" 6 | "github.com/gocql/gocql" 7 | "go.uber.org/zap" 8 | "os" 9 | "os/exec" 10 | "path" 11 | "runtime" 12 | "strconv" 13 | "strings" 14 | "time" 15 | ) 16 | 17 | var started = false 18 | var shouldStartCassandra = false 19 | var shouldStartSimulacron = false 20 | var setupQueries = make([]string, 0) 21 | var setupHandlers = make([]func(), 0) 22 | var createdSchemas = make(map[string]bool) 23 | var session *gocql.Session 24 | 25 | type commandOptions int 26 | 27 | const ( 28 | cmdFatal commandOptions = 1 << iota 29 | cmdNoError 30 | cmdNoOutput 31 | ) 32 | 33 | func (o commandOptions) IsSet(options commandOptions) bool { return o&options != 0 } 34 | 35 | const clusterName = "test" 36 | 37 | func doesClusterExist(name string) bool { 38 | output := executeCcm("list", cmdNoOutput) 39 | nameInUse := "*" + name 40 | for _, cluster := range strings.Fields(output) { 41 | if cluster == name || cluster == nameInUse { 42 | return true 43 | } 44 | } 45 | return false 46 | } 47 | 48 | func keepCluster() bool { 49 | value, _ := strconv.ParseBool(os.Getenv("TEST_KEEP_CLUSTER")) 50 | return value 51 | } 52 | 53 | func startCassandra() bool { 54 | if started { 55 | return false 56 | } 57 | started = true 58 | version := cassandraVersion() 59 | fmt.Printf("Starting Cassandra %s\n", version) 60 | 61 | if !keepCluster() { 62 | executeCcm("stop --not-gently", cmdNoError|cmdNoOutput) 63 | executeCcm(fmt.Sprintf("remove %s", clusterName), cmdNoError|cmdNoOutput) 64 | } 65 | 66 | if !doesClusterExist(clusterName) { 67 | executeCcm(fmt.Sprintf("create %s -v %s -n 1 -s -b", clusterName, version), cmdFatal) 68 | return true 69 | } else { 70 | executeCcm(fmt.Sprintf("switch %s", clusterName), cmdFatal) 71 | executeCcm("start", cmdFatal) 72 | return false 73 | } 74 | } 75 | 76 | func shutdownCassandra() { 77 | fmt.Println("Shutting down cassandra") 78 | if !keepCluster() { 79 | executeCcm(fmt.Sprintf("remove %s", clusterName), 0) 80 | } 81 | } 82 | 83 | func executeCcm(command string, cmdType commandOptions) string { 84 | ccmCommand := fmt.Sprintf("ccm %s", command) 85 | cmd := exec.Command("bash", "-c", ccmCommand) 86 | output, err := cmd.CombinedOutput() 87 | outputStr := string(output) 88 | if outputStr != "" && !cmdType.IsSet(cmdNoOutput) { 89 | fmt.Println("Output", outputStr) 90 | } 91 | if err != nil && !cmdType.IsSet(cmdNoError) { 92 | fmt.Println("Error", err) 93 | if cmdType.IsSet(cmdFatal) { 94 | panic(err) 95 | } 96 | } 97 | return outputStr 98 | } 99 | 100 | func cassandraVersion() string { 101 | version := os.Getenv("CCM_VERSION") 102 | if version == "" { 103 | version = "3.11.6" 104 | } 105 | return version 106 | } 107 | 108 | func CreateSchema(name string) { 109 | if createdSchemas[name] { 110 | return 111 | } 112 | 113 | createdSchemas[name] = true 114 | _, currentFile, _, _ := runtime.Caller(0) 115 | dir := path.Dir(currentFile) 116 | filePath := path.Join(dir, "schemas", name, "schema.cql") 117 | executeCcm(fmt.Sprintf("node1 cqlsh -f %s", filePath), cmdFatal) 118 | } 119 | 120 | func PanicIfError(err error) { 121 | if err != nil { 122 | panic(err) 123 | } 124 | } 125 | 126 | func TestLogger() log.Logger { 127 | if strings.ToUpper(os.Getenv("TEST_TRACE")) == "ON" { 128 | logger, err := zap.NewProduction() 129 | if err != nil { 130 | panic(err) 131 | } 132 | return log.NewZapLogger(logger) 133 | } 134 | 135 | return log.NewZapLogger(zap.NewNop()) 136 | } 137 | 138 | // Gets the shared session for this suite 139 | func GetSession() *gocql.Session { 140 | return session 141 | } 142 | 143 | func EnsureCcmCluster(setupHandler func(), queries ...string) { 144 | shouldStartCassandra = true 145 | 146 | if setupHandler != nil { 147 | setupHandlers = append(setupHandlers, setupHandler) 148 | } 149 | 150 | for _, query := range queries { 151 | setupQueries = append(setupQueries, query) 152 | } 153 | } 154 | 155 | func EnsureSimulacronCluster() { 156 | shouldStartSimulacron = true 157 | } 158 | 159 | func BeforeTestSuite() { 160 | // Start the required process for the suite to run 161 | if shouldStartCassandra { 162 | isNew := startCassandra() 163 | 164 | cluster := gocql.NewCluster("127.0.0.1") 165 | cluster.Timeout = 5 * time.Second 166 | cluster.ConnectTimeout = cluster.Timeout 167 | 168 | var err error 169 | 170 | if session, err = cluster.CreateSession(); err != nil { 171 | panic(err) 172 | } 173 | 174 | if isNew { 175 | for _, query := range setupQueries { 176 | err := session.Query(query).Exec() 177 | PanicIfError(err) 178 | } 179 | 180 | for _, handler := range setupHandlers { 181 | handler() 182 | } 183 | } 184 | } 185 | 186 | if shouldStartSimulacron { 187 | StartSimulacron() 188 | CreateSimulacronCluster(3, 3) 189 | } 190 | } 191 | 192 | func AfterTestSuite() { 193 | // Shutdown previously started processes 194 | if shouldStartCassandra { 195 | session.Close() 196 | shutdownCassandra() 197 | } 198 | 199 | if shouldStartSimulacron { 200 | StopSimulacron() 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /db/session.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/gocql/gocql" 7 | ) 8 | 9 | type QueryOptions struct { 10 | UserOrRole string 11 | Consistency gocql.Consistency 12 | SerialConsistency gocql.SerialConsistency 13 | PageState []byte 14 | PageSize int 15 | Context context.Context 16 | } 17 | 18 | func NewQueryOptions() *QueryOptions { 19 | return &QueryOptions{ 20 | // Set defaults for queries that are not affected by consistency 21 | // But still need the parameters, i.e, DDL queries. 22 | Consistency: gocql.LocalOne, 23 | SerialConsistency: gocql.LocalSerial, 24 | } 25 | } 26 | 27 | func (q *QueryOptions) WithUserOrRole(userOrRole string) *QueryOptions { 28 | q.UserOrRole = userOrRole 29 | return q 30 | } 31 | 32 | func (q *QueryOptions) WithConsistency(consistency gocql.Consistency) *QueryOptions { 33 | q.Consistency = consistency 34 | return q 35 | } 36 | 37 | func (q *QueryOptions) WithSerialConsistency(serialConsistency gocql.SerialConsistency) *QueryOptions { 38 | q.SerialConsistency = serialConsistency 39 | return q 40 | } 41 | 42 | func (q *QueryOptions) WithPageSize(pageSize int) *QueryOptions { 43 | q.PageSize = pageSize 44 | return q 45 | } 46 | 47 | func (q *QueryOptions) WithPageState(pageState []byte) *QueryOptions { 48 | q.PageState = pageState 49 | return q 50 | } 51 | 52 | func (q *QueryOptions) WithContext(ctx context.Context) *QueryOptions { 53 | q.Context = ctx 54 | return q 55 | } 56 | 57 | type Session interface { 58 | // Execute executes a statement without returning row results 59 | Execute(query string, options *QueryOptions, values ...interface{}) error 60 | 61 | // ExecuteIterSimple executes a statement and returns iterator to the result set 62 | ExecuteIter(query string, options *QueryOptions, values ...interface{}) (ResultSet, error) 63 | 64 | // ChangeSchema executes a schema change query and waits for schema agreement 65 | ChangeSchema(query string, options *QueryOptions) error 66 | 67 | //TODO: Extract metadata methods from interface into another interface 68 | KeyspaceMetadata(keyspaceName string) (*gocql.KeyspaceMetadata, error) 69 | } 70 | 71 | type ResultSet interface { 72 | PageState() []byte 73 | Values() []map[string]interface{} 74 | } 75 | 76 | func (r *goCqlResultIterator) PageState() []byte { 77 | return r.pageState 78 | } 79 | 80 | func (r *goCqlResultIterator) Values() []map[string]interface{} { 81 | return r.values 82 | } 83 | 84 | type goCqlResultIterator struct { 85 | pageState []byte 86 | values []map[string]interface{} 87 | } 88 | 89 | func newResultIterator(iter *gocql.Iter) (*goCqlResultIterator, error) { 90 | columns := iter.Columns() 91 | scanner := iter.Scanner() 92 | 93 | items := make([]map[string]interface{}, 0) 94 | 95 | for scanner.Next() { 96 | row, err := mapScan(scanner, columns) 97 | if err != nil { 98 | return nil, err 99 | } 100 | items = append(items, row) 101 | } 102 | 103 | if err := iter.Close(); err != nil { 104 | return nil, err 105 | } 106 | 107 | return &goCqlResultIterator{ 108 | pageState: iter.PageState(), 109 | values: items, 110 | }, nil 111 | } 112 | 113 | type GoCqlSession struct { 114 | ref *gocql.Session 115 | } 116 | 117 | func (db *Db) Execute(query string, options *QueryOptions, values ...interface{}) (ResultSet, error) { 118 | return db.session.ExecuteIter(query, options, values...) 119 | } 120 | 121 | func (db *Db) ExecuteNoResult(query string, options *QueryOptions, values ...interface{}) error { 122 | return db.session.Execute(query, options, values) 123 | } 124 | 125 | func (session *GoCqlSession) Execute(query string, options *QueryOptions, values ...interface{}) error { 126 | _, err := session.ExecuteIter(query, options, values...) 127 | return err 128 | } 129 | 130 | func (session *GoCqlSession) ChangeSchema(query string, options *QueryOptions) error { 131 | err := session.Execute(query, options) 132 | if err != nil { 133 | return err 134 | } 135 | if options.Context != nil { 136 | return session.ref.AwaitSchemaAgreement(options.Context) 137 | } 138 | return nil 139 | } 140 | 141 | func (session *GoCqlSession) ExecuteIter(query string, options *QueryOptions, values ...interface{}) (ResultSet, error) { 142 | q := session.ref.Query(query, values...) 143 | 144 | // Avoid reusing metadata from the prepared statement 145 | // Otherwise, we will not get the [applied] column (https://github.com/gocql/gocql/issues/612) 146 | // Or new columns for SELECT * 147 | q.NoSkipMetadata() 148 | 149 | if options != nil { 150 | q.Consistency(options.Consistency) 151 | 152 | if options.SerialConsistency != gocql.Serial && options.SerialConsistency != gocql.LocalSerial { 153 | return nil, errors.New("Invalid serial consistency") 154 | } 155 | 156 | q.SerialConsistency(options.SerialConsistency) 157 | 158 | if options.PageSize > 0 { 159 | // We don't allow disabling paging 160 | q.PageSize(options.PageSize) 161 | } 162 | 163 | q.PageState(options.PageState) 164 | 165 | if options.UserOrRole != "" { 166 | q.CustomPayload(map[string][]byte{ 167 | "ProxyExecute": []byte(options.UserOrRole), 168 | }) 169 | } 170 | } 171 | return newResultIterator(q.Iter()) 172 | } 173 | 174 | func (session *GoCqlSession) KeyspaceMetadata(keyspaceName string) (*gocql.KeyspaceMetadata, error) { 175 | return session.ref.KeyspaceMetadata(keyspaceName) 176 | } 177 | -------------------------------------------------------------------------------- /internal/testutil/schemas/killrvideo/schema.cql: -------------------------------------------------------------------------------- 1 | // https://github.com/pmcfadin/killrvideo-sample-schema/blob/2.0/killrvideo-schema.cql 2 | 3 | // Sample schema for Apache Cassandra 2.0 4 | 5 | // Drop and recreate the keyspace 6 | DROP KEYSPACE IF EXISTS killrvideo; 7 | CREATE KEYSPACE killrvideo WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 }; 8 | 9 | //Create the schema 10 | use killrvideo; 11 | 12 | // User credentials, keyed by email address so we can authenticate 13 | // Seperated from user in case auth is external (Google, Facebook, etc...) 14 | CREATE TABLE user_credentials 15 | ( 16 | email text, 17 | password text, 18 | userid uuid, 19 | PRIMARY KEY (email) 20 | ); 21 | 22 | // Basic entity table for a user 23 | // UUID for userid to link to auth system 24 | CREATE TABLE users 25 | ( 26 | userid uuid, 27 | firstname varchar, 28 | lastname varchar, 29 | email text, 30 | created_date timestamp, 31 | PRIMARY KEY (userid) 32 | ); 33 | 34 | // Entity table that will store many videos for a unique user 35 | // Meta data - Height, Width, Bit rate, Encoding 36 | // Map thumbnails - stop, url 37 | // Selected thumbnail 38 | CREATE TABLE videos 39 | ( 40 | videoid uuid, 41 | userid uuid, 42 | name varchar, 43 | description varchar, 44 | location text, 45 | location_type int, 46 | preview_image_location text, 47 | preview_thumbnails map, // 48 | tags set, 49 | added_date timestamp, 50 | PRIMARY KEY (videoid) 51 | ); 52 | 53 | // One-to-many from the user point of view 54 | // Also know as a lookup table 55 | CREATE TABLE user_videos 56 | ( 57 | userid uuid, 58 | added_date timestamp, 59 | videoid uuid, 60 | name text, 61 | preview_image_location text, 62 | PRIMARY KEY (userid, added_date, videoid) 63 | ) WITH CLUSTERING ORDER BY (added_date DESC, videoid ASC); 64 | 65 | // Track latest videos, grouped by day (if we ever develop a bad hotspot from the daily grouping here, we could mitigate by 66 | // splitting the row using an arbitrary group number, making the partition key (yyyymmdd, group_number)) 67 | CREATE TABLE latest_videos 68 | ( 69 | yyyymmdd text, 70 | added_date timestamp, 71 | videoid uuid, 72 | name text, 73 | preview_image_location text, 74 | PRIMARY KEY (yyyymmdd, added_date, videoid) 75 | ) WITH CLUSTERING ORDER BY (added_date DESC, videoid ASC); 76 | 77 | // Counter table 78 | CREATE TABLE video_rating 79 | ( 80 | videoid uuid, 81 | rating_counter counter, 82 | rating_total counter, 83 | PRIMARY KEY (videoid) 84 | ); 85 | 86 | // Video ratings by user (to try and mitigate voting multiple times) 87 | CREATE TABLE video_ratings_by_user 88 | ( 89 | videoid uuid, 90 | userid uuid, 91 | rating int, 92 | PRIMARY KEY (videoid, userid) 93 | ); 94 | 95 | // Index for tag keywords 96 | CREATE TABLE videos_by_tag 97 | ( 98 | tag text, 99 | videoid uuid, 100 | added_date timestamp, 101 | name text, 102 | preview_image_location text, 103 | tagged_date timestamp, 104 | PRIMARY KEY (tag, videoid) 105 | ); 106 | 107 | // Inverted index for tags by first letter in the tag 108 | CREATE TABLE tags_by_letter 109 | ( 110 | first_letter text, 111 | tag text, 112 | PRIMARY KEY (first_letter, tag) 113 | ); 114 | 115 | // Comments as a many-to-many 116 | // Looking from the video side to many users 117 | CREATE TABLE comments_by_video 118 | ( 119 | videoid uuid, 120 | commentid timeuuid, 121 | userid uuid, 122 | comment text, 123 | PRIMARY KEY (videoid, commentid) 124 | ) WITH CLUSTERING ORDER BY (commentid DESC); 125 | 126 | // looking from the user side to many videos 127 | CREATE TABLE comments_by_user 128 | ( 129 | userid uuid, 130 | commentid timeuuid, 131 | videoid uuid, 132 | comment text, 133 | PRIMARY KEY (userid, commentid) 134 | ) WITH CLUSTERING ORDER BY (commentid DESC); 135 | 136 | 137 | // Time series wide row with reverse comparator 138 | CREATE TABLE video_event 139 | ( 140 | videoid uuid, 141 | userid uuid, 142 | event varchar, 143 | event_timestamp timeuuid, 144 | video_timestamp bigint, 145 | PRIMARY KEY ((videoid, userid), event_timestamp, event) 146 | ) WITH CLUSTERING ORDER BY (event_timestamp DESC, event ASC); 147 | 148 | // Pending uploaded videos by id 149 | CREATE TABLE uploaded_videos 150 | ( 151 | videoid uuid, 152 | userid uuid, 153 | name text, 154 | description text, 155 | tags set, 156 | added_date timestamp, 157 | jobid text, 158 | PRIMARY KEY (videoid) 159 | ); 160 | 161 | // Same as uploaded_videos just keyed by the encoding job's id 162 | CREATE TABLE uploaded_videos_by_jobid 163 | ( 164 | jobid text, 165 | videoid uuid, 166 | userid uuid, 167 | name text, 168 | description text, 169 | tags set, 170 | added_date timestamp, 171 | PRIMARY KEY (jobid) 172 | ); 173 | 174 | // Log of notifications from Azure Media Services encoding jobs (latest updates first) 175 | CREATE TABLE encoding_job_notifications 176 | ( 177 | jobid text, 178 | status_date timestamp, 179 | etag text, 180 | newstate text, 181 | oldstate text, 182 | PRIMARY KEY (jobid, status_date, etag) 183 | ) WITH CLUSTERING ORDER BY (status_date DESC, etag ASC); -------------------------------------------------------------------------------- /rest/endpoint/v1/routes.go: -------------------------------------------------------------------------------- 1 | package endpoint 2 | 3 | import ( 4 | "github.com/datastax/cassandra-data-apis/config" 5 | "github.com/datastax/cassandra-data-apis/db" 6 | "github.com/datastax/cassandra-data-apis/log" 7 | "github.com/datastax/cassandra-data-apis/types" 8 | "net/http" 9 | "path" 10 | ) 11 | 12 | const ( 13 | keyspaceParam = "keyspaceName" 14 | tableParam = "tableName" 15 | ) 16 | 17 | const ( 18 | KeyspacesPathFormat = "v1/keyspaces" 19 | TablesPathFormat = "v1/keyspaces/%s/tables" 20 | TableSinglePathFormat = "v1/keyspaces/%s/tables/%s" 21 | ColumnsPathFormat = "v1/keyspaces/%s/tables/%s/columns" 22 | ColumnSinglePathFormat = "v1/keyspaces/%s/tables/%s/columns/%s" 23 | RowsPathFormat = "v1/keyspaces/%s/tables/%s/rows" 24 | RowSinglePathFormat = "v1/keyspaces/%s/tables/%s/rows/%s" 25 | QueryPathFormat = "v1/keyspaces/%s/tables/%s/rows/query" 26 | ) 27 | 28 | // routeList describes how to route an endpoint 29 | type routeList struct { 30 | logger log.Logger 31 | params config.UrlParamGetter 32 | dbClient *db.Db 33 | operations config.SchemaOperations 34 | excludedKeyspaces map[string]bool 35 | singleKeyspace string 36 | } 37 | 38 | // Routes returns a slice of all the REST endpoint routes 39 | func Routes(prefix string, operations config.SchemaOperations, singleKeyspace string, cfg config.Config, dbClient *db.Db) []types.Route { 40 | excludedKeyspaces := make(map[string]bool) 41 | for _, ks := range cfg.ExcludedKeyspaces() { 42 | excludedKeyspaces[ks] = true 43 | } 44 | 45 | rl := routeList{ 46 | logger: cfg.Logger(), 47 | params: cfg.RouterInfo().UrlParams(), 48 | dbClient: dbClient, 49 | operations: operations, 50 | excludedKeyspaces: excludedKeyspaces, 51 | singleKeyspace: singleKeyspace, 52 | } 53 | 54 | urlPattern := cfg.RouterInfo().UrlPattern() 55 | 56 | urlKeyspaces := url(prefix, urlPattern, KeyspacesPathFormat) 57 | urlTables := url(prefix, urlPattern, TablesPathFormat, keyspaceParam) 58 | urlSingleTable := url(prefix, urlPattern, TableSinglePathFormat, keyspaceParam, tableParam) 59 | urlColumns := url(prefix, urlPattern, ColumnsPathFormat, keyspaceParam, tableParam) 60 | urlSingleColumn := url(prefix, urlPattern, ColumnSinglePathFormat, keyspaceParam, tableParam, "columnName") 61 | urlRows := url(prefix, urlPattern, RowsPathFormat, keyspaceParam, tableParam) 62 | urlSingleRow := url(prefix, urlPattern, RowSinglePathFormat, keyspaceParam, tableParam, "rowIdentifier") 63 | urlQuery := url(prefix, urlPattern, QueryPathFormat, keyspaceParam, tableParam) 64 | 65 | routes := []types.Route{ 66 | { 67 | Method: http.MethodGet, 68 | Pattern: urlColumns, 69 | Handler: rl.validateKeyspace(rl.GetColumns), 70 | }, 71 | { 72 | Method: http.MethodPost, 73 | Pattern: urlColumns, 74 | Handler: rl.validateKeyspace(rl.isSupported(config.TableAlterAdd, rl.AddColumn)), 75 | }, 76 | { 77 | Method: http.MethodDelete, 78 | Pattern: urlSingleColumn, 79 | Handler: rl.validateKeyspace(rl.isSupported(config.TableAlterDrop, rl.DeleteColumn)), 80 | }, 81 | { 82 | Method: http.MethodGet, 83 | Pattern: urlSingleColumn, 84 | Handler: rl.validateKeyspace(rl.GetColumn), 85 | }, 86 | { 87 | Method: http.MethodPost, 88 | Pattern: urlRows, 89 | Handler: rl.validateKeyspace(rl.AddRow), 90 | }, 91 | { 92 | Method: http.MethodGet, 93 | Pattern: urlSingleRow, 94 | Handler: rl.validateKeyspace(rl.GetRow), 95 | }, 96 | { 97 | Method: http.MethodPut, 98 | Pattern: urlSingleRow, 99 | Handler: rl.validateKeyspace(rl.UpdateRow), 100 | }, 101 | { 102 | Method: http.MethodDelete, 103 | Pattern: urlSingleRow, 104 | Handler: rl.validateKeyspace(rl.DeleteRow), 105 | }, 106 | { 107 | Method: http.MethodPost, 108 | Pattern: urlQuery, 109 | Handler: rl.validateKeyspace(rl.Query), 110 | }, 111 | { 112 | Method: http.MethodGet, 113 | Pattern: urlTables, 114 | Handler: rl.validateKeyspace(rl.GetTables), 115 | }, 116 | { 117 | Method: http.MethodPost, 118 | Pattern: urlTables, 119 | Handler: rl.validateKeyspace(rl.isSupported(config.TableCreate, rl.AddTable)), 120 | }, 121 | { 122 | Method: http.MethodGet, 123 | Pattern: urlSingleTable, 124 | Handler: rl.validateKeyspace(rl.GetTable), 125 | }, 126 | { 127 | Method: http.MethodDelete, 128 | Pattern: urlSingleTable, 129 | Handler: rl.validateKeyspace(rl.isSupported(config.TableDrop, rl.DeleteTable)), 130 | }, 131 | { 132 | Method: http.MethodGet, 133 | Pattern: urlKeyspaces, 134 | Handler: http.HandlerFunc(rl.GetKeyspaces), 135 | }, 136 | } 137 | 138 | return routes 139 | } 140 | 141 | func url(prefix string, urlPattern config.UrlPattern, format string, parameterNames ...string) string { 142 | return path.Join(prefix, urlPattern.UrlPathFormat(format, parameterNames...)) 143 | } 144 | 145 | func (s *routeList) validateKeyspace(next http.HandlerFunc) http.HandlerFunc { 146 | return func(w http.ResponseWriter, r *http.Request) { 147 | keyspaceName := s.params(r, keyspaceParam) 148 | 149 | if s.singleKeyspace != "" && s.singleKeyspace != keyspaceName { 150 | // Only a single keyspace is allowed and it's not the provided one 151 | RespondWithKeyspaceNotAllowed(w) 152 | return 153 | } 154 | 155 | if s.excludedKeyspaces[keyspaceName] { 156 | RespondWithKeyspaceNotAllowed(w) 157 | return 158 | } 159 | 160 | next(w, r) 161 | } 162 | } 163 | 164 | func (s *routeList) isSupported(requiredOp config.SchemaOperations, handler http.HandlerFunc) http.HandlerFunc { 165 | if s.operations.IsSupported(requiredOp) { 166 | return handler 167 | } 168 | 169 | return forbiddenHandler 170 | } 171 | 172 | func forbiddenHandler(w http.ResponseWriter, _ *http.Request) { 173 | http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) 174 | } 175 | -------------------------------------------------------------------------------- /db/query_generators.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/datastax/cassandra-data-apis/types" 7 | "github.com/gocql/gocql" 8 | ) 9 | 10 | type SelectInfo struct { 11 | Keyspace string 12 | Table string 13 | Columns []string 14 | Where []types.ConditionItem 15 | Options *types.QueryOptions 16 | OrderBy []ColumnOrder 17 | } 18 | 19 | type InsertInfo struct { 20 | Keyspace string 21 | Table string 22 | Columns []string 23 | QueryParams []interface{} 24 | IfNotExists bool 25 | TTL int 26 | } 27 | 28 | type DeleteInfo struct { 29 | Keyspace string 30 | Table string 31 | Columns []string 32 | QueryParams []interface{} 33 | IfCondition []types.ConditionItem 34 | IfExists bool 35 | } 36 | 37 | type UpdateInfo struct { 38 | Keyspace string 39 | Table *gocql.TableMetadata 40 | Columns []string 41 | QueryParams []interface{} 42 | IfCondition []types.ConditionItem 43 | IfExists bool 44 | TTL int 45 | } 46 | 47 | type ColumnOrder struct { 48 | Column string 49 | Order string 50 | } 51 | 52 | func (db *Db) Select(info *SelectInfo, options *QueryOptions) (ResultSet, error) { 53 | values := make([]interface{}, 0, len(info.Where)) 54 | whereClause := buildCondition(info.Where, &values) 55 | columns := " *" 56 | 57 | if len(info.Columns) > 0 { 58 | columns = "" 59 | for _, columnName := range info.Columns { 60 | columns += fmt.Sprintf(`, "%s"`, columnName) 61 | } 62 | } 63 | 64 | query := fmt.Sprintf(`SELECT %s FROM "%s"."%s"`, columns[2:], info.Keyspace, info.Table) 65 | 66 | if whereClause != "" { 67 | query += fmt.Sprintf(" WHERE %s", whereClause) 68 | } 69 | 70 | if len(info.OrderBy) > 0 { 71 | query += " ORDER BY " 72 | for i, order := range info.OrderBy { 73 | if i > 0 { 74 | query += ", " 75 | } 76 | query += fmt.Sprintf(`"%s" %s`, order.Column, order.Order) 77 | } 78 | } 79 | 80 | if info.Options != nil && info.Options.Limit > 0 { 81 | query += " LIMIT ?" 82 | values = append(values, info.Options.Limit) 83 | } 84 | 85 | return db.session.ExecuteIter(query, options, values...) 86 | } 87 | 88 | func (db *Db) Insert(info *InsertInfo, options *QueryOptions) (ResultSet, error) { 89 | placeholders := "" 90 | columns := "" 91 | for _, columnName := range info.Columns { 92 | placeholders += ", ?" 93 | columns += fmt.Sprintf(`, "%s"`, columnName) 94 | } 95 | 96 | query := fmt.Sprintf( 97 | `INSERT INTO "%s"."%s" (%s) VALUES (%s)`, info.Keyspace, info.Table, 98 | // Remove the initial ", " token 99 | columns[2:], placeholders[2:]) 100 | 101 | if info.IfNotExists { 102 | query += " IF NOT EXISTS" 103 | } 104 | 105 | if info.TTL >= 0 { 106 | query += " USING TTL ?" 107 | info.QueryParams = append(info.QueryParams, info.TTL) 108 | } 109 | 110 | return db.session.ExecuteIter(query, options, info.QueryParams...) 111 | } 112 | 113 | func (db *Db) Delete(info *DeleteInfo, options *QueryOptions) (ResultSet, error) { 114 | whereClause := buildWhereClause(info.Columns) 115 | query := fmt.Sprintf(`DELETE FROM "%s"."%s" WHERE %s`, info.Keyspace, info.Table, whereClause) 116 | queryParameters := make([]interface{}, len(info.QueryParams)) 117 | copy(queryParameters, info.QueryParams) 118 | 119 | if info.IfExists { 120 | query += " IF EXISTS" 121 | } else if len(info.IfCondition) > 0 { 122 | query += " IF " + buildCondition(info.IfCondition, &queryParameters) 123 | } 124 | 125 | return db.session.ExecuteIter(query, options, queryParameters...) 126 | } 127 | 128 | func (db *Db) Update(info *UpdateInfo, options *QueryOptions) (ResultSet, error) { 129 | // We have to differentiate between WHERE and SET clauses 130 | setClause := "" 131 | whereClause := "" 132 | setParameters := make([]interface{}, 0, len(info.QueryParams)) 133 | whereParameters := make([]interface{}, 0, len(info.QueryParams)) 134 | 135 | for i, columnName := range info.Columns { 136 | column, ok := info.Table.Columns[columnName] 137 | if ok && (column.Kind == gocql.ColumnPartitionKey || column.Kind == gocql.ColumnClusteringKey) { 138 | whereClause += fmt.Sprintf(` AND "%s" = ?`, columnName) 139 | whereParameters = append(whereParameters, info.QueryParams[i]) 140 | } else { 141 | setClause += fmt.Sprintf(`, "%s" = ?`, columnName) 142 | setParameters = append(setParameters, info.QueryParams[i]) 143 | } 144 | } 145 | 146 | if len(whereClause) == 0 { 147 | return nil, errors.New("Partition and clustering keys must be included in query") 148 | } 149 | if len(setClause) == 0 { 150 | return nil, errors.New("Query must include columns to update") 151 | } 152 | 153 | queryParameters := make([]interface{}, 0, len(info.QueryParams)) 154 | 155 | ttl := "" 156 | if info.TTL >= 0 { 157 | ttl = " USING TTL ?" 158 | queryParameters = append(queryParameters, info.TTL) 159 | } 160 | 161 | for _, v := range setParameters { 162 | queryParameters = append(queryParameters, v) 163 | } 164 | for _, v := range whereParameters { 165 | queryParameters = append(queryParameters, v) 166 | } 167 | 168 | // Remove the initial AND operator 169 | whereClause = whereClause[5:] 170 | // Remove the initial ", " token 171 | setClause = setClause[2:] 172 | 173 | query := fmt.Sprintf( 174 | `UPDATE "%s"."%s"%s SET %s WHERE %s`, info.Keyspace, info.Table.Name, ttl, setClause, whereClause) 175 | 176 | if info.IfExists { 177 | query += " IF EXISTS" 178 | } else if len(info.IfCondition) > 0 { 179 | query += " IF " + buildCondition(info.IfCondition, &queryParameters) 180 | } 181 | 182 | return db.session.ExecuteIter(query, options, queryParameters...) 183 | } 184 | 185 | func buildWhereClause(columnNames []string) string { 186 | whereClause := "" 187 | for _, name := range columnNames { 188 | whereClause += fmt.Sprintf(` AND "%s" = ?`, name) 189 | } 190 | 191 | // Remove initial " AND " characters 192 | return whereClause[5:] 193 | } 194 | 195 | func buildCondition(condition []types.ConditionItem, queryParameters *[]interface{}) string { 196 | if len(condition) == 0 { 197 | return "" 198 | } 199 | 200 | conditionClause := "" 201 | for _, item := range condition { 202 | conditionClause += fmt.Sprintf(` AND "%s" %s ?`, item.Column, item.Operator) 203 | *queryParameters = append(*queryParameters, item.Value) 204 | } 205 | 206 | // Remove initial " AND " characters 207 | return conditionClause[5:] 208 | } 209 | -------------------------------------------------------------------------------- /config/naming.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "github.com/iancoleman/strcase" 6 | "sort" 7 | "strings" 8 | ) 9 | 10 | type NamingConvention interface { 11 | // ToCQLColumn converts a GraphQL/REST name to a CQL column name. 12 | ToCQLColumn(tableName string, fieldName string) string 13 | 14 | // ToCQLColumn converts a GraphQL/REST name to a CQL table name. 15 | ToCQLTable(name string) string 16 | 17 | // ToGraphQLField converts a CQL name (typically a column name) to a GraphQL field name. 18 | ToGraphQLField(tableName string, columnName string) string 19 | 20 | // ToGraphQLOperation converts a CQL name (typically a table name) to a GraphQL operation name. 21 | ToGraphQLOperation(prefix string, name string) string 22 | 23 | // ToGraphQLType converts a CQL name (typically a table name) to a GraphQL type name. 24 | ToGraphQLType(name string) string 25 | 26 | // ToGraphQLType converts a CQL table name to a GraphQL while also checking that the type name doesn't collide 27 | // with types generated by a table name. 28 | // For example: calls for ToGraphQLTypeUnique("tbl_one", "input") will return 29 | // - "TblOneInput" when there isn't a table that generates the same name 30 | // - "TblOneInput2" when there is a table that generates the same name 31 | ToGraphQLTypeUnique(name string, suffix string) string 32 | } 33 | 34 | type NamingConventionFn func(KeyspaceNamingInfo) NamingConvention 35 | 36 | type KeyspaceNamingInfo interface { 37 | // A map containing the table names as keys and the column names as values 38 | Tables() map[string][]string 39 | } 40 | 41 | const reservedNameSuffix = "Custom" 42 | 43 | func NewDefaultNaming(info KeyspaceNamingInfo) NamingConvention { 44 | dbTables := info.Tables() 45 | tableNames := make([]string, 0, len(dbTables)) 46 | for k := range dbTables { 47 | tableNames = append(tableNames, k) 48 | } 49 | // Use a sorted slice of table names to have deterministic behaviour (map keys order is not guaranteed) 50 | sort.Strings(tableNames) 51 | 52 | entitiesByTables := make(map[string]string, len(dbTables)) 53 | tablesByEntities := make(map[string]string, len(dbTables)) 54 | fieldsByColumns := make(map[string]map[string]string, len(dbTables)) 55 | columnsByFields := make(map[string]map[string]string, len(dbTables)) 56 | 57 | for _, tableName := range tableNames { 58 | columns := dbTables[tableName] 59 | fieldByColumnName := make(map[string]string, len(columns)) 60 | columnNameByField := make(map[string]string, len(columns)) 61 | 62 | for _, columnName := range columns { 63 | fieldName := generateAvailableName(strcase.ToLowerCamel(columnName), columnNameByField) 64 | fieldByColumnName[columnName] = fieldName 65 | columnNameByField[fieldName] = columnName 66 | } 67 | 68 | entityName := strcase.ToCamel(tableName) 69 | if isReserved(entityName) { 70 | entityName += reservedNameSuffix 71 | } 72 | entityName = generateAvailableName(entityName, tablesByEntities) 73 | 74 | entitiesByTables[tableName] = entityName 75 | fieldsByColumns[tableName] = fieldByColumnName 76 | columnsByFields[tableName] = columnNameByField 77 | tablesByEntities[entityName] = tableName 78 | } 79 | 80 | result := snakeCaseToCamelNaming{ 81 | entitiesByTables: entitiesByTables, 82 | tablesByEntities: tablesByEntities, 83 | fieldsByColumns: fieldsByColumns, 84 | columnsByFields: columnsByFields, 85 | } 86 | return &result 87 | } 88 | 89 | func generateAvailableName(baseName string, nameMap map[string]string) string { 90 | if _, found := nameMap[baseName]; !found { 91 | return baseName 92 | } 93 | for i := 2; i < 1000; i++ { 94 | name := fmt.Sprintf("%s%d", baseName, i) 95 | _, found := nameMap[name] 96 | if !found { 97 | return name 98 | } 99 | } 100 | 101 | panic("Name was repeated more than 1000 times") 102 | } 103 | 104 | func isReserved(name string) bool { 105 | switch name { 106 | case "BasicType", "Bigint", "Blob", "Column", "ColumnInput", "ColumnKind", "Consistency", "ClusteringKeyInput", 107 | "DataType", "DataTypeInput", "Decimal", "QueryOptions", "Table", "Query", "Mutation", "Time", 108 | "Timestamp", "TimeUuid", "UpdateOptions", "Uuid", "Varint": 109 | return true 110 | } 111 | 112 | if strings.HasSuffix(name, "FilterInput") { 113 | // We create one input type per scalar, like IntFilterInput 114 | // It's best to mark anything that "FilterInput" as reserved 115 | return true 116 | } 117 | 118 | return false 119 | } 120 | 121 | type snakeCaseToCamelNaming struct { 122 | entitiesByTables map[string]string 123 | tablesByEntities map[string]string 124 | columnsByFields map[string]map[string]string 125 | fieldsByColumns map[string]map[string]string 126 | } 127 | 128 | func (n *snakeCaseToCamelNaming) ToCQLColumn(tableName string, fieldName string) string { 129 | // lookup column by fields 130 | columnName, found := n.columnsByFields[tableName][fieldName] 131 | if !found { 132 | return strcase.ToSnake(fieldName) 133 | } 134 | return columnName 135 | } 136 | 137 | func (n *snakeCaseToCamelNaming) ToCQLTable(name string) string { 138 | // lookup table name by entity name 139 | tableName, found := n.tablesByEntities[name] 140 | if !found { 141 | // Default to snake_case for tables that doesn't exist yet (DDL) 142 | return strcase.ToSnake(name) 143 | } 144 | return tableName 145 | } 146 | 147 | func (n *snakeCaseToCamelNaming) ToGraphQLField(tableName string, columnName string) string { 148 | // lookup fields by columns 149 | fieldName, found := n.fieldsByColumns[tableName][columnName] 150 | if !found { 151 | return strcase.ToLowerCamel(columnName) 152 | } 153 | return fieldName 154 | } 155 | 156 | func (n *snakeCaseToCamelNaming) ToGraphQLOperation(prefix string, tableName string) string { 157 | entityName := n.ToGraphQLType(tableName) 158 | if prefix == "" { 159 | return strcase.ToLowerCamel(entityName) 160 | } else { 161 | return strcase.ToLowerCamel(prefix) + entityName 162 | } 163 | } 164 | 165 | func (n *snakeCaseToCamelNaming) ToGraphQLType(name string) string { 166 | entityName, found := n.entitiesByTables[name] 167 | if !found { 168 | // Default to Camel for entities that doesn't exist yet (DDL) 169 | return strcase.ToCamel(name) 170 | } 171 | return entityName 172 | } 173 | 174 | func (n *snakeCaseToCamelNaming) ToGraphQLTypeUnique(name string, suffix string) string { 175 | entityName := n.ToGraphQLType(name) 176 | return generateAvailableName(entityName+strcase.ToCamel(suffix), n.tablesByEntities) 177 | } 178 | -------------------------------------------------------------------------------- /graphql/routes.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "github.com/datastax/cassandra-data-apis/config" 8 | "github.com/datastax/cassandra-data-apis/db" 9 | "github.com/datastax/cassandra-data-apis/log" 10 | "github.com/datastax/cassandra-data-apis/types" 11 | "github.com/graphql-go/graphql" 12 | "net/http" 13 | "path" 14 | "regexp" 15 | "strings" 16 | "time" 17 | ) 18 | 19 | type executeQueryFunc func(request RequestBody, urlPath string, ctx context.Context) *graphql.Result 20 | 21 | type RouteGenerator struct { 22 | dbClient *db.Db 23 | updateInterval time.Duration 24 | logger log.Logger 25 | schemaGen *SchemaGenerator 26 | routerInfo config.HttpRouterInfo 27 | } 28 | 29 | type Config struct { 30 | ksExcluded []string 31 | } 32 | 33 | type RequestBody struct { 34 | Query string `json:"query"` 35 | OperationName string `json:"operationName"` 36 | Variables map[string]interface{} `json:"variables"` 37 | } 38 | 39 | func NewRouteGenerator(dbClient *db.Db, cfg config.Config) *RouteGenerator { 40 | return &RouteGenerator{ 41 | dbClient: dbClient, 42 | updateInterval: cfg.SchemaUpdateInterval(), 43 | logger: cfg.Logger(), 44 | schemaGen: NewSchemaGenerator(dbClient, cfg), 45 | routerInfo: cfg.RouterInfo(), 46 | } 47 | } 48 | 49 | func (rg *RouteGenerator) RoutesSchemaManagement(pattern string, singleKeyspace string, ops config.SchemaOperations) ([]types.Route, error) { 50 | schema, err := rg.schemaGen.BuildKeyspaceSchema(singleKeyspace, ops) 51 | if err != nil { 52 | return nil, fmt.Errorf("unable to build graphql schema for schema management: %s", err) 53 | } 54 | return routesForSchema(pattern, func(request RequestBody, urlPath string, ctx context.Context) *graphql.Result { 55 | return rg.executeQuery(request, ctx, schema) 56 | }), nil 57 | } 58 | 59 | func (rg *RouteGenerator) Routes(pattern string, singleKeyspace string) ([]types.Route, error) { 60 | updater, err := NewUpdater(rg.schemaGen, singleKeyspace, rg.updateInterval, rg.logger) 61 | if err != nil { 62 | return nil, fmt.Errorf("unable to build graphql schema: %s", err) 63 | } 64 | 65 | go updater.Start() 66 | 67 | pathParser := getPathParser(pattern) 68 | if singleKeyspace == "" { 69 | // Use a single route with keyspace as dynamic parameter 70 | pattern = rg.routerInfo.UrlPattern().UrlPathFormat(path.Join(pattern, "%s"), "keyspace") 71 | } 72 | 73 | return routesForSchema(pattern, func(request RequestBody, urlPath string, ctx context.Context) *graphql.Result { 74 | ksName := singleKeyspace 75 | if ksName == "" { 76 | // Multiple keyspace support 77 | // The keyspace is part of the url path 78 | ksName = pathParser(urlPath) 79 | if ksName == "" { 80 | // Invalid url parameter 81 | return nil 82 | } 83 | } 84 | schema := updater.Schema(ksName) 85 | 86 | if schema == nil { 87 | // The keyspace was not found or is invalid 88 | return nil 89 | } 90 | 91 | return rg.executeQuery(request, ctx, *schema) 92 | }), nil 93 | } 94 | 95 | // Keyspaces gets a slice of keyspace names that are considered by the route generator. 96 | func (rg *RouteGenerator) Keyspaces() ([]string, error) { 97 | keyspaces, err := rg.dbClient.Keyspaces("") 98 | 99 | if err != nil { 100 | return nil, err 101 | } 102 | 103 | result := make([]string, 0, len(keyspaces)) 104 | for _, ksName := range keyspaces { 105 | if !rg.schemaGen.isKeyspaceExcluded(ksName) { 106 | result = append(result, ksName) 107 | } 108 | } 109 | 110 | return result, nil 111 | } 112 | 113 | func getPathParser(root string) func(string) string { 114 | if !strings.HasSuffix(root, "/") { 115 | root += "/" 116 | } 117 | regexString := fmt.Sprintf(`^%s([\w-]+)/?(?:\?.*)?$`, root) 118 | r := regexp.MustCompile(regexString) 119 | return func(urlPath string) string { 120 | subMatches := r.FindStringSubmatch(urlPath) 121 | if len(subMatches) != 2 { 122 | return "" 123 | } 124 | return subMatches[1] 125 | } 126 | } 127 | 128 | func routesForSchema(pattern string, execute executeQueryFunc) []types.Route { 129 | return []types.Route{ 130 | { 131 | Method: http.MethodGet, 132 | Pattern: pattern, 133 | Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 134 | vars := r.URL.Query().Get("variables") 135 | 136 | var variables map[string]interface{} 137 | if len(vars) > 0 { 138 | err := json.Unmarshal([]byte(vars), &variables) 139 | if err != nil { 140 | http.Error(w, "'variables' query variable is invalid: " + err.Error(), 400) 141 | return 142 | } 143 | } 144 | 145 | result := execute(RequestBody{ 146 | Query: r.URL.Query().Get("query"), 147 | OperationName: r.URL.Query().Get("operationName"), 148 | Variables: variables, 149 | }, r.URL.Path, r.Context()) 150 | if result == nil { 151 | // The execution function is signaling that it shouldn't be processing this request 152 | http.NotFound(w, r) 153 | return 154 | } 155 | err := json.NewEncoder(w).Encode(result) 156 | if err != nil { 157 | http.Error(w, "response could not be encoded: "+err.Error(), 500) 158 | } 159 | }), 160 | }, 161 | { 162 | Method: http.MethodPost, 163 | Pattern: pattern, 164 | Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 165 | if r.Body == nil { 166 | http.Error(w, "No request body", 400) 167 | return 168 | } 169 | 170 | var body RequestBody 171 | err := json.NewDecoder(r.Body).Decode(&body) 172 | if err != nil { 173 | http.Error(w, "request body is invalid", 400) 174 | return 175 | } 176 | 177 | result := execute(body, r.URL.Path, r.Context()) 178 | if result == nil { 179 | // The execution function is signaling that it shouldn't be processing this request 180 | http.NotFound(w, r) 181 | return 182 | } 183 | 184 | err = json.NewEncoder(w).Encode(result) 185 | if err != nil { 186 | http.Error(w, "response could not be encoded: "+err.Error(), 500) 187 | } 188 | }), 189 | }, 190 | } 191 | } 192 | 193 | func (rg *RouteGenerator) executeQuery(request RequestBody, ctx context.Context, schema graphql.Schema) *graphql.Result { 194 | result := graphql.Do(graphql.Params{ 195 | Schema: schema, 196 | Context: ctx, 197 | RequestString: request.Query, 198 | OperationName: request.OperationName, 199 | VariableValues: request.Variables, 200 | }) 201 | if len(result.Errors) > 0 { 202 | rg.logger.Error("unexpected errors processing graphql query", "errors", result.Errors) 203 | } 204 | return result 205 | } 206 | -------------------------------------------------------------------------------- /types/conversions.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "encoding" 5 | "encoding/base64" 6 | "errors" 7 | "fmt" 8 | "github.com/gocql/gocql" 9 | "gopkg.in/inf.v0" 10 | "math/big" 11 | "strings" 12 | "time" 13 | ) 14 | 15 | type toJsonFn func(value interface{}) interface{} 16 | type fromJsonFn func(value interface{}) (interface{}, error) 17 | 18 | func ToJsonValues(rows []map[string]interface{}, table *gocql.TableMetadata) []map[string]interface{} { 19 | rowsLength := len(rows) 20 | if rowsLength == 0 { 21 | return rows 22 | } 23 | 24 | firstRow := rows[0] 25 | columnsLength := len(firstRow) 26 | converters := make(map[string]toJsonFn, columnsLength) 27 | result := make([]map[string]interface{}, rowsLength) 28 | 29 | for columnName := range firstRow { 30 | column := table.Columns[columnName] 31 | converters[columnName] = jsonConverterPerType(column.Type) 32 | } 33 | 34 | for i := 0; i < rowsLength; i++ { 35 | row := rows[i] 36 | item := make(map[string]interface{}, columnsLength) 37 | 38 | for columnName, value := range row { 39 | converter := converters[columnName] 40 | if value != nil { 41 | item[columnName] = converter(value) 42 | } else { 43 | item[columnName] = nil 44 | } 45 | } 46 | 47 | result[i] = item 48 | } 49 | 50 | return result 51 | } 52 | 53 | func FromJsonValue(value interface{}, typeInfo gocql.TypeInfo) (interface{}, error) { 54 | switch typeInfo.Type() { 55 | case gocql.TypeTimestamp: 56 | return StringToTime(value) 57 | case gocql.TypeDecimal: 58 | return StringToDecimal(value) 59 | case gocql.TypeVarint: 60 | return StringToBigInt(value) 61 | case gocql.TypeInt, gocql.TypeTinyInt, gocql.TypeSmallInt: 62 | return FloatToInt(value) 63 | case gocql.TypeBlob: 64 | return Base64StringToByteArray(value) 65 | case gocql.TypeFloat: 66 | return Float64ToFloat32(value) 67 | case gocql.TypeTime: 68 | return CqlFormattedStringToDuration(value) 69 | } 70 | return value, nil 71 | } 72 | 73 | func jsonConverterPerType(typeInfo gocql.TypeInfo) toJsonFn { 74 | switch typeInfo.Type() { 75 | case gocql.TypeVarint, gocql.TypeDecimal: 76 | return StringerToString 77 | case gocql.TypeBlob: 78 | return ByteArrayToBase64String 79 | case gocql.TypeTimestamp: 80 | return TimeAsString 81 | case gocql.TypeTime: 82 | return DurationToCqlFormattedString 83 | } 84 | 85 | return identityFn 86 | } 87 | 88 | func identityFn(value interface{}) interface{} { 89 | return value 90 | } 91 | 92 | func StringerToString(value interface{}) interface{} { 93 | switch value := value.(type) { 94 | case fmt.Stringer: 95 | if value == nil { 96 | return value 97 | } 98 | return value.String() 99 | default: 100 | return value 101 | } 102 | } 103 | 104 | func ByteArrayToBase64String(value interface{}) interface{} { 105 | switch value := value.(type) { 106 | case *[]byte: 107 | if value == nil { 108 | return value 109 | } 110 | return base64.StdEncoding.EncodeToString(*value) 111 | default: 112 | return value 113 | } 114 | } 115 | 116 | func TimeAsString(value interface{}) interface{} { 117 | switch value := value.(type) { 118 | case *time.Time: 119 | if value == nil { 120 | return value 121 | } 122 | return marshalText(value) 123 | default: 124 | return value 125 | } 126 | } 127 | 128 | func marshalText(value encoding.TextMarshaler) *string { 129 | buff, err := value.MarshalText() 130 | if err != nil { 131 | return nil 132 | } 133 | 134 | var s = string(buff) 135 | return &s 136 | } 137 | 138 | func DurationToCqlFormattedString(value interface{}) interface{} { 139 | switch value := value.(type) { 140 | case *time.Duration: 141 | if value == nil { 142 | return value 143 | } 144 | d := *value 145 | totalSeconds := d.Truncate(time.Second) 146 | remainingNanos := d - totalSeconds 147 | 148 | var ( 149 | hours = 0 150 | minutes = 0 151 | ) 152 | secs := int(totalSeconds.Seconds()) 153 | 154 | if secs >= 60 { 155 | minutes = secs / 60 156 | secs = secs % 60 157 | } 158 | if minutes >= 60 { 159 | hours = minutes / 60 160 | minutes = minutes % 60 161 | } 162 | 163 | nanosStr := "" 164 | if remainingNanos > 0 { 165 | nanosStr = fmt.Sprintf(".%09d", remainingNanos.Nanoseconds()) 166 | } 167 | return fmt.Sprintf("%02d:%02d:%02d%s", hours, minutes, secs, nanosStr) 168 | default: 169 | return value 170 | } 171 | } 172 | 173 | func CqlFormattedStringToDuration(value interface{}) (interface{}, error) { 174 | switch value := value.(type) { 175 | case string: 176 | parts := strings.Split(value, ":") 177 | if len(parts) != 3 { 178 | return nil, errors.New("time has wrong format") 179 | } 180 | 181 | secs := parts[2] 182 | nanos := "0" 183 | if strings.Contains(parts[2], ".") { 184 | secParts := strings.Split(parts[2], ".") 185 | secs = secParts[0] 186 | nanos = secParts[1] 187 | // Pad right zeros 188 | if len(nanos) < 9 { 189 | nanos = nanos + strings.Repeat("0", 9-len(nanos)) 190 | } 191 | } 192 | 193 | duration, err := time.ParseDuration(fmt.Sprintf("%sh%sm%ss%sns", parts[0], parts[1], secs, nanos)) 194 | if err != nil { 195 | return nil, errors.New("time has wrong format") 196 | } 197 | return duration, nil 198 | default: 199 | return value, nil 200 | } 201 | } 202 | 203 | func Base64StringToByteArray(value interface{}) (interface{}, error) { 204 | switch value := value.(type) { 205 | case string: 206 | return base64.StdEncoding.DecodeString(value) 207 | default: 208 | return value, nil 209 | } 210 | } 211 | 212 | func FloatToInt(value interface{}) (interface{}, error) { 213 | if f, ok := value.(float64); ok { 214 | return int(f), nil 215 | } 216 | 217 | return nil, errors.New("wrong value provided for int type") 218 | } 219 | 220 | func Float64ToFloat32(value interface{}) (interface{}, error) { 221 | if f, ok := value.(float64); ok { 222 | return float32(f), nil 223 | } 224 | 225 | return nil, errors.New("wrong value provided for float (32) type") 226 | } 227 | 228 | func unmarshallerToText(factory func() encoding.TextUnmarshaler) fromJsonFn { 229 | return func(value interface{}) (interface{}, error) { 230 | switch value := value.(type) { 231 | case string: 232 | t := factory() 233 | err := t.UnmarshalText([]byte(value)) 234 | if err != nil { 235 | return nil, err 236 | } 237 | 238 | return t, nil 239 | default: 240 | return value, nil 241 | } 242 | } 243 | } 244 | 245 | var StringToTime fromJsonFn = unmarshallerToText(func() encoding.TextUnmarshaler { 246 | return &time.Time{} 247 | }) 248 | 249 | var StringToDecimal = unmarshallerToText(func() encoding.TextUnmarshaler { 250 | return &inf.Dec{} 251 | }) 252 | 253 | var StringToBigInt = unmarshallerToText(func() encoding.TextUnmarshaler { 254 | return &big.Int{} 255 | }) 256 | -------------------------------------------------------------------------------- /db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "fmt" 5 | "github.com/datastax/cassandra-data-apis/config" 6 | e "github.com/datastax/cassandra-data-apis/errors" 7 | "github.com/gocql/gocql" 8 | "time" 9 | ) 10 | 11 | func ifNotExistsStr(ifNotExists bool) string { 12 | if ifNotExists { 13 | return "IF NOT EXISTS " 14 | } 15 | return "" 16 | } 17 | 18 | func ifExistsStr(ifExists bool) string { 19 | if ifExists { 20 | return "IF EXISTS " 21 | } 22 | return "" 23 | } 24 | 25 | // Db represents a connection to a db 26 | type Db struct { 27 | session Session 28 | } 29 | 30 | type SslOptions struct { 31 | CaPath string 32 | CertPath string 33 | KeyPath string 34 | HostVerification bool 35 | } 36 | 37 | type Config struct { 38 | Username string 39 | Password string 40 | SslOptions *SslOptions 41 | } 42 | 43 | // NewDb Gets a pointer to a db 44 | func NewDb(config Config, hosts ...string) (*Db, error) { 45 | cluster := gocql.NewCluster(hosts...) 46 | cluster.PoolConfig = gocql.PoolConfig{ 47 | HostSelectionPolicy: NewDefaultHostSelectionPolicy(), 48 | } 49 | 50 | // Match DataStax drivers settings 51 | cluster.ConnectTimeout = 5 * time.Second 52 | cluster.Timeout = 12 * time.Second 53 | 54 | if config.Username != "" && config.Password != "" { 55 | cluster.Authenticator = gocql.PasswordAuthenticator{ 56 | Username: config.Username, 57 | Password: config.Password, 58 | } 59 | } 60 | 61 | if config.SslOptions != nil { 62 | cluster.SslOpts = &gocql.SslOptions{ 63 | CertPath: config.SslOptions.CertPath, 64 | KeyPath: config.SslOptions.KeyPath, 65 | CaPath: config.SslOptions.CaPath, 66 | EnableHostVerification: config.SslOptions.HostVerification, 67 | } 68 | } 69 | 70 | var ( 71 | session *gocql.Session 72 | err error 73 | ) 74 | 75 | if session, err = cluster.CreateSession(); err != nil { 76 | return nil, err 77 | } 78 | return NewDbWithSession(&GoCqlSession{ref: session}), nil 79 | } 80 | 81 | func NewDbWithSession(session Session) *Db { 82 | return &Db{ 83 | session: session, 84 | } 85 | } 86 | 87 | func NewDbWithConnectedInstance(session *gocql.Session) *Db { 88 | return &Db{session: &GoCqlSession{ref: session}} 89 | } 90 | 91 | // Keyspace retrieves the keyspace metadata for all users 92 | func (db *Db) Keyspace(keyspace string) (*gocql.KeyspaceMetadata, error) { 93 | ks, err := db.session.KeyspaceMetadata(keyspace) 94 | 95 | if err != nil && err.Error() == "keyspace does not exist" { 96 | return nil, &DbObjectNotFound{"keyspace", keyspace} 97 | } 98 | 99 | return ks, err 100 | } 101 | 102 | // Table retrieves the table metadata for all users 103 | func (db *Db) Table(keyspaceName string, tableName string) (*gocql.TableMetadata, error) { 104 | ks, err := db.Keyspace(keyspaceName) 105 | 106 | if err != nil { 107 | return nil, err 108 | } 109 | 110 | table, ok := ks.Tables[tableName] 111 | 112 | if !ok { 113 | return nil, &DbObjectNotFound{"table", tableName} 114 | } 115 | 116 | return table, nil 117 | } 118 | 119 | // KeyspaceNamingInfo Retrieves the keyspace naming information 120 | func (db *Db) KeyspaceNamingInfo(ks *gocql.KeyspaceMetadata) config.KeyspaceNamingInfo { 121 | result := keyspaceNamingInfo{ 122 | tables: make(map[string][]string, len(ks.Tables)), 123 | } 124 | 125 | for _, table := range ks.Tables { 126 | columns := make([]string, 0, len(table.Columns)) 127 | for k := range table.Columns { 128 | columns = append(columns, k) 129 | } 130 | result.tables[table.Name] = columns 131 | } 132 | 133 | return &result 134 | } 135 | 136 | type keyspaceNamingInfo struct { 137 | tables map[string][]string 138 | } 139 | 140 | func (k *keyspaceNamingInfo) Tables() map[string][]string { 141 | return k.tables 142 | } 143 | 144 | // Keyspaces Retrieves all the keyspace names 145 | func (db *Db) Keyspaces(userOrRole string) ([]string, error) { 146 | iter, err := db.session.ExecuteIter("SELECT keyspace_name FROM system_schema.keyspaces", 147 | NewQueryOptions().WithUserOrRole(userOrRole)) 148 | if err != nil { 149 | return nil, err 150 | } 151 | 152 | var keyspaces []string 153 | for _, row := range iter.Values() { 154 | keyspaces = append(keyspaces, *row["keyspace_name"].(*string)) 155 | } 156 | 157 | return keyspaces, nil 158 | } 159 | 160 | // Views Retrieves all the views for the given keyspace 161 | func (db *Db) Views(ksName string) (map[string]bool, error) { 162 | iter, err := db.session.ExecuteIter("SELECT view_name FROM system_schema.views WHERE keyspace_name = ?", nil, ksName) 163 | if err != nil { 164 | return nil, err 165 | } 166 | 167 | views := make(map[string]bool, len(iter.Values())) 168 | for _, row := range iter.Values() { 169 | views[*row["view_name"].(*string)] = true 170 | } 171 | 172 | return views, nil 173 | } 174 | 175 | // DescribeTables returns the tables that the user is authorized to see 176 | func (db *Db) DescribeTable(keyspace, table, username string) (*gocql.TableMetadata, error) { 177 | // Query system_schema first to make sure user is authorized 178 | stmt := "SELECT table_name FROM system_schema.tables WHERE keyspace_name = ? AND table_name = ?" 179 | 180 | result, retErr := db.Execute(stmt, NewQueryOptions().WithUserOrRole(username), keyspace, table) 181 | if retErr != nil { 182 | return nil, retErr 183 | } 184 | 185 | if len(result.Values()) == 0 { 186 | return nil, e.NewNotFoundError(fmt.Sprintf("table %s in keyspace %s not found", table, keyspace)) 187 | } 188 | 189 | keyspaceMetadata, retErr := db.Keyspace(keyspace) 190 | if retErr != nil { 191 | return nil, retErr 192 | } 193 | 194 | tableMetadata, found := keyspaceMetadata.Tables[table] 195 | if found { 196 | return tableMetadata, nil 197 | } 198 | 199 | return nil, e.NewNotFoundError(fmt.Sprintf("table %s in keyspace %s not found", table, keyspace)) 200 | } 201 | 202 | // DescribeTables returns the tables that the user is authorized to see 203 | func (db *Db) DescribeTables(keyspace, username string) ([]string, error) { 204 | // Query system_schema to make sure user is authorized 205 | stmt := "SELECT table_name FROM system_schema.tables WHERE keyspace_name = ?" 206 | result, retErr := db.Execute(stmt, NewQueryOptions().WithUserOrRole(username), keyspace) 207 | if retErr != nil { 208 | return nil, retErr 209 | } 210 | 211 | tables := make([]string, 0, len(result.Values())) 212 | for _, row := range result.Values() { 213 | value := row["table_name"].(*string) 214 | if value == nil { 215 | continue 216 | } 217 | tables = append(tables, *value) 218 | } 219 | 220 | return tables, nil 221 | } 222 | 223 | type DbObjectNotFound struct { 224 | objectType string 225 | keyspace string 226 | } 227 | 228 | func (e *DbObjectNotFound) Error() string { 229 | return fmt.Sprintf("%s '%s' does not exist", e.objectType, e.keyspace) 230 | } 231 | -------------------------------------------------------------------------------- /db/type_mapping_test.go: -------------------------------------------------------------------------------- 1 | // +build integration 2 | 3 | package db 4 | 5 | import ( 6 | "fmt" 7 | "github.com/datastax/cassandra-data-apis/internal/testutil" 8 | . "github.com/onsi/ginkgo" 9 | . "github.com/onsi/gomega" 10 | "github.com/stretchr/testify/assert" 11 | "gopkg.in/inf.v0" 12 | "math/big" 13 | "reflect" 14 | "strings" 15 | "time" 16 | ) 17 | 18 | var _ = Describe("Session", func() { 19 | testutil.EnsureCcmCluster( 20 | nil, "CREATE KEYSPACE ks1 WITH replication = {'class': 'SimpleStrategy', 'replication_factor' : 1}") 21 | 22 | var db *Db 23 | BeforeEach(func() { 24 | db = NewDbWithConnectedInstance(testutil.GetSession()) 25 | }) 26 | 27 | Describe("ExecuteIter()", func() { 28 | Context("With numerical values", func() { 29 | It("Should provide the expected representation", func() { 30 | queries := []string{ 31 | "CREATE TABLE ks1.tbl_numerics (id int PRIMARY KEY, bigint_value bigint, float_value float," + 32 | " double_value double, smallint_value smallint, tinyint_value tinyint, decimal_value decimal," + 33 | " varint_value varint)", 34 | "INSERT INTO ks1.tbl_numerics (id, bigint_value, float_value, double_value, smallint_value, tinyint_value" + 35 | ", decimal_value, varint_value) VALUES (1, 1, 1.1, 1.1, 1, 1, 1.25, 1)", 36 | "INSERT INTO ks1.tbl_numerics (id) VALUES (100)", 37 | } 38 | 39 | for _, query := range queries { 40 | err := db.session.Execute(query, nil) 41 | Expect(err).To(BeNil()) 42 | } 43 | 44 | rs, err := db.session.ExecuteIter("SELECT * FROM ks1.tbl_numerics WHERE id = ?", nil, 1) 45 | assert.Nil(GinkgoT(), err) 46 | row := rs.Values()[0] 47 | assertPointer(new(string), "1", row["bigint_value"]) 48 | assertPointer(new(float32), float32(1.1), row["float_value"]) 49 | assertPointer(new(float64), 1.1, row["double_value"]) 50 | assertPointer(new(int16), int16(1), row["smallint_value"]) 51 | assertPointer(new(int8), int8(1), row["tinyint_value"]) 52 | assertPointer(new(inf.Dec), *inf.NewDec(125, 2), row["decimal_value"]) 53 | assertPointer(new(big.Int), *big.NewInt(1), row["varint_value"]) 54 | 55 | // Assert nil values 56 | rs, err = db.session.ExecuteIter("SELECT * FROM ks1.tbl_numerics WHERE id = ?", nil, 100) 57 | assert.Nil(GinkgoT(), err) 58 | row = rs.Values()[0] 59 | assertNilPointer(new(string), row["bigint_value"]) 60 | assertNilPointer(new(float32), row["float_value"]) 61 | assertNilPointer(new(float64), row["double_value"]) 62 | assertNilPointer(new(int16), row["smallint_value"]) 63 | assertNilPointer(new(int8), row["tinyint_value"]) 64 | assertNilPointer(new(inf.Dec), row["decimal_value"]) 65 | assertNilPointer(new(big.Int), row["varint_value"]) 66 | }) 67 | }) 68 | 69 | Context("With lists and sets", func() { 70 | It("Should provide the expected representation", func() { 71 | queries := []string{ 72 | "CREATE TABLE ks1.tbl_lists (id int PRIMARY KEY, int_value list, bigint_value list," + 73 | " float_value list, double_value list, bool_value list, text_value list)", 74 | "INSERT INTO ks1.tbl_lists (id, int_value, bigint_value, float_value, double_value" + 75 | ", bool_value, text_value) VALUES (1, [1], [1], [1.1], [2.1], [true], ['hello'])", 76 | "INSERT INTO ks1.tbl_lists (id) VALUES (100)", 77 | } 78 | 79 | for _, query := range queries { 80 | err := db.session.Execute(query, nil) 81 | assert.Nil(GinkgoT(), err) 82 | } 83 | 84 | var ( 85 | rs ResultSet 86 | err error 87 | row map[string]interface{} 88 | ) 89 | 90 | //TODO: Test nulls and sets 91 | rs, err = db.session.ExecuteIter("SELECT * FROM ks1.tbl_lists WHERE id = ?", nil, 1) 92 | assert.Nil(GinkgoT(), err) 93 | row = rs.Values()[0] 94 | assertPointer(new([]int), []int{1}, row["int_value"]) 95 | assertPointer(new([]string), []string{"1"}, row["bigint_value"]) 96 | assertPointer(new([]float32), []float32{1.1}, row["float_value"]) 97 | assertPointer(new([]float64), []float64{2.1}, row["double_value"]) 98 | assertPointer(new([]bool), []bool{true}, row["bool_value"]) 99 | assertPointer(new([]string), []string{"hello"}, row["text_value"]) 100 | }) 101 | }) 102 | 103 | Context("With maps", func() { 104 | It("Should provide the expected representation", func() { 105 | queries := []string{ 106 | "CREATE TABLE ks1.tbl_maps (id int PRIMARY KEY, m1 map, m2 map," + 107 | " m3 map>>, m4 map)", 108 | "INSERT INTO ks1.tbl_maps (id, m1, m2, m3, m4) VALUES (1, {'a': 1}, {1: 1.1}" + 109 | ", {e639af03-7851-49d7-a711-5ba81a0ff9c5: [1, 2]}, {4: 'four'})", 110 | "INSERT INTO ks1.tbl_maps (id) VALUES (100)", 111 | } 112 | 113 | for _, query := range queries { 114 | err := db.session.Execute(query, nil) 115 | assert.Nil(GinkgoT(), err) 116 | } 117 | 118 | var ( 119 | rs ResultSet 120 | err error 121 | row map[string]interface{} 122 | ) 123 | 124 | rs, err = db.session.ExecuteIter("SELECT * FROM ks1.tbl_maps WHERE id = ?", nil, 1) 125 | assert.Nil(GinkgoT(), err) 126 | row = rs.Values()[0] 127 | assertPointer(new(map[string]int), map[string]int{"a": 1}, row["m1"]) 128 | assertPointer(new(map[string]float64), map[string]float64{"1": 1.1}, row["m2"]) 129 | assertPointer(new(map[string][]int), map[string][]int{"e639af03-7851-49d7-a711-5ba81a0ff9c5": {1, 2}}, row["m3"]) 130 | assertPointer(new(map[int16]string), map[int16]string{int16(4): "four"}, row["m4"]) 131 | }) 132 | }) 133 | 134 | Context("With scalars", func() { 135 | It("Should provide the expected representation", func() { 136 | 137 | queries := []string{ 138 | "CREATE TABLE ks1.tbl_scalars (id int PRIMARY KEY, inet_value inet, uuid_value uuid, timeuuid_value timeuuid," + 139 | " timestamp_value timestamp, blob_value blob)", 140 | "INSERT INTO ks1.tbl_scalars (id) VALUES (100)", 141 | } 142 | 143 | for _, query := range queries { 144 | err := db.session.Execute(query, nil) 145 | assert.Nil(GinkgoT(), err) 146 | } 147 | 148 | id := 1 149 | timeValue := time.Time{} 150 | _ = timeValue.UnmarshalText([]byte("2019-12-31T23:59:59.999Z")) 151 | values := map[string]interface{}{ 152 | "id": id, 153 | "inet_value": "10.10.150.1", 154 | "uuid_value": "d2b99a72-4482-4064-8f96-ca7aba39a1ca", 155 | "timeuuid_value": "308f185c-7272-11ea-bc55-0242ac130003", 156 | "timestamp_value": timeValue, 157 | "blob_value": []byte{1, 2, 3, 4}, 158 | } 159 | columns := make([]string, 0) 160 | parameters := make([]interface{}, 0) 161 | for k, v := range values { 162 | columns = append(columns, k) 163 | parameters = append(parameters, v) 164 | } 165 | 166 | insertQuery := fmt.Sprintf("INSERT INTO ks1.tbl_scalars (%s) VALUES (?%s)", 167 | strings.Join(columns, ", "), strings.Repeat(", ?", len(columns)-1)) 168 | _, err := db.session.ExecuteIter(insertQuery, nil, parameters...) 169 | assert.Nil(GinkgoT(), err) 170 | 171 | selectQuery := fmt.Sprintf("SELECT %s FROM ks1.tbl_scalars WHERE id = ?", strings.Join(columns, ", ")) 172 | rs, err := db.session.ExecuteIter(selectQuery, nil, id) 173 | assert.Nil(GinkgoT(), err) 174 | row := rs.Values()[0] 175 | for key, value := range values { 176 | assertPointerValue(value, row[key]) 177 | } 178 | }) 179 | }) 180 | }) 181 | }) 182 | 183 | func assertPointer(expectedType interface{}, expected interface{}, actual interface{}) { 184 | assert.IsType(GinkgoT(), expectedType, actual) 185 | assertPointerValue(expected, actual) 186 | } 187 | 188 | func assertPointerValue(expected interface{}, actual interface{}) { 189 | assert.Equal(GinkgoT(), expected, reflect.ValueOf(actual).Elem().Interface()) 190 | } 191 | 192 | func assertNilPointer(expectedType interface{}, actual interface{}) { 193 | assert.IsType(GinkgoT(), expectedType, actual) 194 | assert.Nil(GinkgoT(), actual) 195 | } 196 | -------------------------------------------------------------------------------- /graphql/resolvers.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "github.com/datastax/cassandra-data-apis/db" 7 | "github.com/datastax/cassandra-data-apis/types" 8 | "github.com/gocql/gocql" 9 | "github.com/graphql-go/graphql" 10 | "github.com/mitchellh/mapstructure" 11 | "gopkg.in/inf.v0" 12 | "math/big" 13 | "reflect" 14 | "strings" 15 | "time" 16 | ) 17 | 18 | type mutationOperation int 19 | 20 | const ( 21 | insertOperation mutationOperation = iota 22 | updateOperation 23 | deleteOperation 24 | ) 25 | 26 | func (sg *SchemaGenerator) queryFieldResolver( 27 | table *gocql.TableMetadata, 28 | ksSchema *KeyspaceGraphQLSchema, 29 | isFilter bool, 30 | ) graphql.FieldResolveFn { 31 | return func(params graphql.ResolveParams) (interface{}, error) { 32 | // GraphQL operation is lower camel 33 | var value map[string]interface{} 34 | if isFilter { 35 | value = params.Args["filter"].(map[string]interface{}) 36 | } else if params.Args["value"] != nil { 37 | value = params.Args["value"].(map[string]interface{}) 38 | } 39 | 40 | var whereClause []types.ConditionItem 41 | 42 | if !isFilter { 43 | whereClause = make([]types.ConditionItem, 0, len(value)) 44 | for key, value := range value { 45 | whereClause = append(whereClause, types.ConditionItem{ 46 | Column: ksSchema.naming.ToCQLColumn(table.Name, key), 47 | Operator: "=", 48 | Value: adaptParameterValue(value), 49 | }) 50 | } 51 | } else { 52 | whereClause = ksSchema.adaptCondition(table.Name, value) 53 | } 54 | 55 | var orderBy []interface{} 56 | var options types.QueryOptions 57 | if err := mapstructure.Decode(params.Args["options"], &options); err != nil { 58 | return nil, err 59 | } 60 | 61 | if params.Args["orderBy"] != nil { 62 | orderBy = params.Args["orderBy"].([]interface{}) 63 | } 64 | 65 | userOrRole, err := sg.checkUserOrRoleAuth(params) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | pageState, err := base64.StdEncoding.DecodeString(options.PageState) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | result, err := sg.dbClient.Select( 76 | &db.SelectInfo{ 77 | Keyspace: table.Keyspace, 78 | Table: table.Name, 79 | Where: whereClause, 80 | OrderBy: parseColumnOrder(orderBy), 81 | Options: &options, 82 | }, 83 | db.NewQueryOptions(). 84 | WithUserOrRole(userOrRole). 85 | WithPageSize(options.PageSize). 86 | WithPageState(pageState). 87 | WithConsistency(gocql.Consistency(options.Consistency))) 88 | 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | return &types.QueryResult{ 94 | PageState: base64.StdEncoding.EncodeToString(result.PageState()), 95 | Values: ksSchema.adaptResult(table.Name, result.Values()), 96 | }, nil 97 | } 98 | } 99 | 100 | func (sg *SchemaGenerator) mutationFieldResolver( 101 | table *gocql.TableMetadata, 102 | ksSchema *KeyspaceGraphQLSchema, 103 | operation mutationOperation, 104 | ) graphql.FieldResolveFn { 105 | return func(params graphql.ResolveParams) (interface{}, error) { 106 | value := params.Args["value"].(map[string]interface{}) 107 | columnNames := make([]string, 0, len(value)) 108 | queryParams := make([]interface{}, 0, len(value)) 109 | 110 | for key, value := range value { 111 | columnNames = append(columnNames, ksSchema.naming.ToCQLColumn(table.Name, key)) 112 | queryParams = append(queryParams, adaptParameterValue(value)) 113 | } 114 | 115 | var options types.MutationOptions 116 | if err := mapstructure.Decode(params.Args["options"], &options); err != nil { 117 | return nil, err 118 | } 119 | 120 | userOrRole, err := sg.checkUserOrRoleAuth(params) 121 | if err != nil { 122 | return nil, err 123 | } 124 | 125 | queryOptions := db.NewQueryOptions(). 126 | WithUserOrRole(userOrRole). 127 | WithConsistency(gocql.Consistency(options.Consistency)). 128 | WithSerialConsistency(gocql.SerialConsistency(options.SerialConsistency)) 129 | 130 | var result db.ResultSet 131 | 132 | switch operation { 133 | case insertOperation: 134 | ifNotExists := params.Args["ifNotExists"] == true 135 | result, err = sg.dbClient.Insert(&db.InsertInfo{ 136 | Keyspace: table.Keyspace, 137 | Table: table.Name, 138 | Columns: columnNames, 139 | QueryParams: queryParams, 140 | IfNotExists: ifNotExists, 141 | TTL: options.TTL, 142 | }, queryOptions) 143 | case deleteOperation: 144 | var ifCondition []types.ConditionItem 145 | if params.Args["ifCondition"] != nil { 146 | ifCondition = ksSchema.adaptCondition( 147 | table.Name, params.Args["ifCondition"].(map[string]interface{})) 148 | } 149 | result, err = sg.dbClient.Delete(&db.DeleteInfo{ 150 | Keyspace: table.Keyspace, 151 | Table: table.Name, 152 | Columns: columnNames, 153 | QueryParams: queryParams, 154 | IfCondition: ifCondition, 155 | IfExists: params.Args["ifExists"] == true}, queryOptions) 156 | case updateOperation: 157 | var ifCondition []types.ConditionItem 158 | if params.Args["ifCondition"] != nil { 159 | ifCondition = ksSchema.adaptCondition( 160 | table.Name, params.Args["ifCondition"].(map[string]interface{})) 161 | } 162 | result, err = sg.dbClient.Update(&db.UpdateInfo{ 163 | Keyspace: table.Keyspace, 164 | Table: table, 165 | Columns: columnNames, 166 | QueryParams: queryParams, 167 | IfCondition: ifCondition, 168 | TTL: options.TTL, 169 | IfExists: params.Args["ifExists"] == true}, queryOptions) 170 | default: 171 | return false, fmt.Errorf("operation not supported") 172 | } 173 | 174 | return ksSchema.getModificationResult(table, value, result, err) 175 | } 176 | } 177 | 178 | func adaptParameterValue(value interface{}) interface{} { 179 | if value == nil { 180 | return nil 181 | } 182 | 183 | switch value.(type) { 184 | case int8, int16, int, float32, float64, string, bool: 185 | // Avoid using reflection for common scalars 186 | // Ideally, the algorithm should function without this optimization 187 | return value 188 | } 189 | 190 | return adaptCollectionParameter(value) 191 | } 192 | 193 | func adaptCollectionParameter(value interface{}) interface{} { 194 | rv := reflect.ValueOf(value) 195 | switch rv.Type().Kind() { 196 | case reflect.Slice: 197 | // Type element (rv.Type().Elem()) is an interface{} 198 | // We have to inspect the first value 199 | length := rv.Len() 200 | if length == 0 { 201 | return value 202 | } 203 | firstElement := rv.Index(0) 204 | if reflect.TypeOf(firstElement.Interface()).Kind() != reflect.Map { 205 | return value 206 | } 207 | 208 | result := make(map[interface{}]interface{}) 209 | // It's a slice of maps that only contains two keys: 'key' and 'value' 210 | // It's the graphql representation of a map: [KeyValueType] 211 | for i := 0; i < length; i++ { 212 | element := rv.Index(i).Interface().(map[string]interface{}) 213 | result[element["key"]] = adaptParameterValue(element["value"]) 214 | } 215 | 216 | return result 217 | } 218 | 219 | return value 220 | } 221 | 222 | func parseColumnOrder(values []interface{}) []db.ColumnOrder { 223 | result := make([]db.ColumnOrder, 0, len(values)) 224 | 225 | for _, value := range values { 226 | strValue := value.(string) 227 | index := strings.LastIndex(strValue, "_") 228 | result = append(result, db.ColumnOrder{ 229 | Column: strValue[0:index], 230 | Order: strValue[index+1:], 231 | }) 232 | } 233 | 234 | return result 235 | } 236 | 237 | func adaptResultValue(value interface{}) interface{} { 238 | if value == nil { 239 | return nil 240 | } 241 | 242 | switch value.(type) { 243 | case *int8, *int16, *int, *float32, *float64, *int32, *string, *bool, 244 | *time.Time, *inf.Dec, *big.Int, *gocql.UUID, *[]byte: 245 | // Avoid reflection whenever possible 246 | return value 247 | } 248 | 249 | rv := reflect.ValueOf(value) 250 | typeKind := rv.Type().Kind() 251 | 252 | if typeKind == reflect.Ptr && rv.IsNil() { 253 | return nil 254 | } 255 | 256 | if !(typeKind == reflect.Ptr && rv.Elem().Type().Kind() == reflect.Map) { 257 | return value 258 | } 259 | 260 | rv = rv.Elem() 261 | 262 | // Maps should be adapted to a slice of maps, each map containing 2 keys: 'key' and 'value' 263 | result := make([]map[string]interface{}, 0, rv.Len()) 264 | iter := rv.MapRange() 265 | for iter.Next() { 266 | key := iter.Key() 267 | value := iter.Value() 268 | result = append(result, map[string]interface{}{ 269 | "key": key.Interface(), 270 | "value": value.Interface(), 271 | }) 272 | } 273 | 274 | return result 275 | } 276 | -------------------------------------------------------------------------------- /internal/testutil/schemas/datatypes/datatypes.go: -------------------------------------------------------------------------------- 1 | package datatypes 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "github.com/datastax/cassandra-data-apis/internal/testutil/schemas" 7 | "github.com/datastax/cassandra-data-apis/types" 8 | "github.com/gocql/gocql" 9 | . "github.com/onsi/gomega" 10 | "math" 11 | ) 12 | 13 | type ConvertFn func(value interface{}) interface{} 14 | 15 | func identityConvert(value interface{}) interface{} { 16 | return value 17 | } 18 | 19 | func MutateAndQueryScalar( 20 | routes []types.Route, 21 | datatype string, 22 | graphqlDatatype string, 23 | value interface{}, 24 | format string, 25 | convert ConvertFn, 26 | jsonConvert ConvertFn, 27 | ) { 28 | insertQuery := `mutation { 29 | insertScalars(value:{id:"%s", %sCol:%s}) { 30 | applied 31 | } 32 | }` 33 | insertQueryVariables := `mutation InsertScalars($value: %s) { 34 | insertScalars(value:{id:"%s", %sCol:$value}) { 35 | applied 36 | } 37 | }` 38 | selectQuery := `query { 39 | scalars(value:{id:"%s"}) { 40 | values { 41 | id 42 | %sCol 43 | } 44 | } 45 | }` 46 | deleteQuery := `mutation { 47 | deleteScalars(value:{id:"%s"}) { 48 | applied 49 | } 50 | }` 51 | updateQuery := `mutation { 52 | updateScalars(value:{id:"%s", %sCol:%s}) { 53 | applied 54 | } 55 | }` 56 | 57 | valueStr := fmt.Sprintf(format, value) 58 | id := schemas.NewUuid() 59 | var buffer *bytes.Buffer 60 | var values []map[string]interface{} 61 | 62 | if convert == nil { convert = identityConvert 63 | } 64 | if jsonConvert == nil { jsonConvert = identityConvert 65 | } 66 | 67 | // Insert 68 | buffer = schemas.ExecutePost(routes, "/graphql", fmt.Sprintf(insertQuery, id, datatype, valueStr)) 69 | Expect(schemas.DecodeData(buffer, "insertScalars")["applied"]).To(Equal(true)) 70 | 71 | // Select 72 | buffer = schemas.ExecutePost(routes, "/graphql", fmt.Sprintf(selectQuery, id, datatype)) 73 | values = schemas.DecodeDataAsSliceOfMaps(buffer, "scalars", "values") 74 | Expect(convert(values[0][datatype+"Col"])).To(Equal(value)) 75 | 76 | // Insert with variables 77 | buffer = schemas.ExecutePostWithVariables(routes, 78 | "/graphql", 79 | fmt.Sprintf(insertQueryVariables, graphqlDatatype, id, datatype), 80 | map[string]interface{}{ 81 | "value": jsonConvert(value), 82 | }) 83 | Expect(schemas.DecodeData(buffer, "insertScalars")["applied"]).To(Equal(true)) 84 | 85 | // Verify value after inserting with variables 86 | buffer = schemas.ExecutePost(routes, "/graphql", fmt.Sprintf(selectQuery, id, datatype)) 87 | values = schemas.DecodeDataAsSliceOfMaps(buffer, "scalars", "values") 88 | Expect(convert(values[0][datatype+"Col"])).To(Equal(value)) 89 | 90 | // Delete 91 | buffer = schemas.ExecutePost(routes, "/graphql", fmt.Sprintf(deleteQuery, id)) 92 | Expect(schemas.DecodeData(buffer, "deleteScalars")["applied"]).To(Equal(true)) 93 | 94 | // Verify deleted 95 | buffer = schemas.ExecutePost(routes, "/graphql", fmt.Sprintf(selectQuery, id, datatype)) 96 | Expect(schemas.DecodeDataAsSliceOfMaps(buffer, "scalars", "values")).To(HaveLen(0)) 97 | 98 | // Update 99 | buffer = schemas.ExecutePost(routes, "/graphql", fmt.Sprintf(updateQuery, id, datatype, valueStr)) 100 | Expect(schemas.DecodeData(buffer, "updateScalars")["applied"]).To(Equal(true)) 101 | 102 | // Verify updated 103 | buffer = schemas.ExecutePost(routes, "/graphql", fmt.Sprintf(selectQuery, id, datatype)) 104 | values = schemas.DecodeDataAsSliceOfMaps(buffer, "scalars", "values") 105 | Expect(convert(values[0][datatype+"Col"])).To(Equal(value)) 106 | } 107 | 108 | func InsertScalarErrors(routes []types.Route, datatype string, value string) { 109 | insertQuery := `mutation { 110 | insertScalars(value:{id:"%s", %sCol:%s}) { 111 | applied 112 | } 113 | }` 114 | 115 | buffer := schemas.ExecutePost(routes, "/graphql", fmt.Sprintf(insertQuery, schemas.NewUuid(), datatype, value)) 116 | response := schemas.DecodeResponse(buffer) 117 | Expect(response.Errors).To(HaveLen(1)) 118 | Expect(response.Errors[0].Message).To(ContainSubstring("invalid")) 119 | } 120 | 121 | func InsertAndUpdateNulls(routes []types.Route, datatype string, jsonValue interface{}) { 122 | insertQuery := `mutation { 123 | insertScalars(value:{id:"%s", %sCol:%s}) { 124 | applied 125 | } 126 | }` 127 | selectQuery := `query { 128 | scalars(value:{id:"%s"}) { 129 | values { 130 | id 131 | %sCol 132 | } 133 | } 134 | }` 135 | updateQuery := `mutation { 136 | updateScalars(value:{id:"%s", %sCol:null}) { 137 | applied 138 | } 139 | }` 140 | 141 | valueStr := fmt.Sprintf("%v", jsonValue) 142 | if _, ok := jsonValue.(string); ok { 143 | valueStr = fmt.Sprintf(`"%s"`, jsonValue) 144 | } 145 | id := schemas.NewUuid() 146 | var buffer *bytes.Buffer 147 | var values []map[string]interface{} 148 | 149 | // Insert 150 | buffer = schemas.ExecutePost(routes, "/graphql", fmt.Sprintf(insertQuery, id, datatype, valueStr)) 151 | Expect(schemas.DecodeData(buffer, "insertScalars")["applied"]).To(Equal(true)) 152 | 153 | // Select 154 | buffer = schemas.ExecutePost(routes, "/graphql", fmt.Sprintf(selectQuery, id, datatype)) 155 | values = schemas.DecodeDataAsSliceOfMaps(buffer, "scalars", "values") 156 | Expect(values[0][datatype+"Col"]).To(Equal(jsonValue)) 157 | 158 | // Update 159 | buffer = schemas.ExecutePost(routes, "/graphql", fmt.Sprintf(updateQuery, id, datatype)) 160 | Expect(schemas.DecodeData(buffer, "updateScalars")["applied"]).To(Equal(true)) 161 | 162 | // Select 163 | buffer = schemas.ExecutePost(routes, "/graphql", fmt.Sprintf(selectQuery, id, datatype)) 164 | values = schemas.DecodeDataAsSliceOfMaps(buffer, "scalars", "values") 165 | Expect(values[0][datatype+"Col"]).To(BeNil()) 166 | } 167 | 168 | func MutateAndQueryCollection( 169 | routes []types.Route, 170 | fieldName string, 171 | stringValue string, 172 | jsonValue []interface{}, 173 | isMap bool, 174 | ) { 175 | updateQuery := `mutation { 176 | updateCollections(value:{id: "%s", %s: %s}) { 177 | applied 178 | } 179 | }` 180 | selectQuery := `query { 181 | collections(value:{id:"%s"}) { 182 | values { 183 | id 184 | %s 185 | } 186 | } 187 | }` 188 | 189 | id := schemas.NewUuid() 190 | buffer := schemas.ExecutePost(routes, "/graphql", fmt.Sprintf(updateQuery, id, fieldName, stringValue)) 191 | Expect(schemas.DecodeData(buffer, "updateCollections")["applied"]).To(Equal(true)) 192 | 193 | selectFieldName := fieldName 194 | if isMap { 195 | selectFieldName = fmt.Sprintf("%s {key, value}", fieldName) 196 | } 197 | 198 | buffer = schemas.ExecutePost(routes, "/graphql", fmt.Sprintf(selectQuery, id, selectFieldName)) 199 | values := schemas.DecodeDataAsSliceOfMaps(buffer, "collections", "values") 200 | value := values[0][fieldName] 201 | if !isMap { 202 | Expect(value).To(Equal(jsonValue)) 203 | } else { 204 | Expect(value).To(ContainElements(jsonValue)) 205 | } 206 | } 207 | 208 | func MutateAndQueryStatic(routes []types.Route) { 209 | insertQueryWithStatic := `mutation { 210 | insertTableStatic(value:{id1: "%s", id2: %d, value: %d, valueStatic: %v}) { 211 | applied 212 | } 213 | }` 214 | insertQuery := `mutation { 215 | insertTableStatic(value:{id1: "%s", id2: %d, value: %d}) { 216 | applied 217 | } 218 | }` 219 | selectQuery := `query { 220 | tableStatic(value:{id1:"%s"}) { 221 | values { 222 | id1 223 | id2 224 | value 225 | valueStatic 226 | } 227 | } 228 | }` 229 | 230 | id := schemas.NewUuid() 231 | jsonValue := float64(100) 232 | 233 | // Insert 2 rows in the same partition, one including the static value 234 | buffer := schemas.ExecutePost(routes, "/graphql", fmt.Sprintf(insertQueryWithStatic, id, 1, 1, jsonValue)) 235 | Expect(schemas.DecodeData(buffer, "insertTableStatic")["applied"]).To(Equal(true)) 236 | buffer = schemas.ExecutePost(routes, "/graphql", fmt.Sprintf(insertQuery, id, 2, 2)) 237 | Expect(schemas.DecodeData(buffer, "insertTableStatic")["applied"]).To(Equal(true)) 238 | 239 | // Select 240 | buffer = schemas.ExecutePost(routes, "/graphql", fmt.Sprintf(selectQuery, id)) 241 | values := schemas.DecodeDataAsSliceOfMaps(buffer, "tableStatic", "values") 242 | Expect(values).To(HaveLen(2)) 243 | // The static value should be present in all rows for the partition 244 | for _, value := range values { 245 | Expect(value["valueStatic"]).To(Equal(jsonValue)) 246 | } 247 | } 248 | 249 | // ScalarJsonValues gets a slice containing one slice per scalar data type with name in first position and json values in 250 | // the following positions. 251 | func ScalarJsonValues() [][]interface{} { 252 | return [][]interface{}{ 253 | {"float", float64(0), float64(-1), 1.25, 3.40282}, 254 | {"double", float64(1), float64(0), -1.25, math.MaxFloat64}, 255 | {"boolean", true, false}, 256 | {"tinyint", float64(1)}, 257 | {"int", float64(2)}, 258 | {"bigint", "123"}, 259 | {"varint", "123"}, 260 | {"decimal", "123.080000"}, 261 | {"timeuuid", gocql.TimeUUID().String()}, 262 | {"uuid", schemas.NewUuid()}, 263 | {"inet", "10.11.150.201"}, 264 | {"blob", "ABEi"}, 265 | {"timestamp", "2005-08-05T13:20:21.52Z"}, 266 | {"time", "08:45:02"}, 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Data APIs for Apache Cassandra (Deprecated) 2 | 3 | (Project has been moved to Stargate) 4 | 5 | Easy to use APIs for accessing data stored in Apache Cassandra. 6 | 7 | These APIs can be used as a standalone server using either Docker or manually 8 | running a server. They can also be embedded in existing applications using HTTP 9 | routes. 10 | 11 | Currently, this project provides GraphQL APIs. Other API types are possible in 12 | the future. 13 | 14 | ## Getting Started 15 | 16 | ### Installation 17 | 18 | ```sh 19 | docker pull datastaxlabs/cassandra-data-apis 20 | docker run -p 8080:8080 -e DATA_API_HOSTS= datastaxlabs/cassandra-data-apis 21 | ``` 22 | 23 | You can also manually build the docker image and/or the server using the 24 | [instructions](#building) below. 25 | 26 | #### Running in the background 27 | 28 | You can start the container in detached mode by using `-d` and `--rm` flags. 29 | 30 | ```sh 31 | docker run --rm -d -p 8080:8080 -e DATA_API_HOSTS= datastaxlabs/cassandra-data-apis 32 | ``` 33 | 34 | #### Running on macOS or Windows for development purposes 35 | 36 | When using Docker for Desktop, if your Cassandra instance is listening on the loopback address `127.0.0.1`, 37 | you can use `host.docker.internal` name which resolves to the internal IP address used by the host. 38 | 39 | ```sh 40 | docker run -p 8080:8080 -e DATA_API_HOSTS=host.docker.internal datastaxlabs/cassandra-data-apis 41 | ``` 42 | 43 | ### Using GraphQL 44 | 45 | By default, a GraphQL endpoint is started and will generate a GraphQL schema per keyspace. You need at least one 46 | user-defined keyspace in your database to get started. 47 | 48 | Use the [GraphQL documentation](/docs/graphql/README.md) for getting started. 49 | 50 | ## Configuration 51 | 52 | Configuration for Docker can be done using either environment variables, a 53 | mounted configuration file, or both. 54 | 55 | Add additional configuration using environment variables by adding them to the 56 | `docker run` command. 57 | 58 | ``` 59 | docker run -e DATA_API_HOSTS=127.0.0.1 -e DATA_API_KEYSPACE=example ... 60 | ``` 61 | 62 | ### Using a configuration file 63 | 64 | To use a configuration file, create a file with the following contents: 65 | 66 | ```yaml 67 | hosts: 68 | # Change to your cluster's hosts 69 | - 127.0.0.1 70 | # keyspace: example 71 | # username: cassandra 72 | # password: cassandra 73 | 74 | # See the "Settings" section for additional configuration 75 | 76 | ``` 77 | 78 | Then start docker with: 79 | 80 | ```sh 81 | docker run -p 8080:8080 -v "${PWD}/.yaml:/root/config.yaml" datastaxlabs/cassandra-data-apis 82 | ``` 83 | 84 | ### Settings 85 | 86 | | Name | Type | Env. Variable | Description | 87 | | --- | --- | --- | --- | 88 | | hosts | strings | DATA_API_HOSTS | Hosts for connecting to the database | 89 | | keyspace | string | DATA_API_KEYSPACE | Only allow access to a single keyspace | 90 | | excluded-keyspaces | strings | DATA_API_EXCLUDED_KEYSPACES | Keyspaces to exclude from the endpoint | 91 | | username | string | DATA_API_USERNAME | Connect with database user | 92 | | password | string | DATA_API_PASSWORD | Database user's password | 93 | | operations | strings | DATA_API_OPERATIONS | A list of supported schema management operations. See below. (default `"TableCreate, KeyspaceCreate"`) | 94 | | request-logging | bool | DATA_API_REQUEST_LOGGING | Enable request logging | 95 | | schema-update-interval | duration | DATA_API_SCHEMA_UPDATE_INTERVAL | Interval in seconds used to update the graphql schema (default `10s`) | 96 | | ssl-enabled | bool | DATA_API_SSL_ENABLED | Enable SSL (client-to-node encryption)? | 97 | | ssl-ca-cert-path | string | DATA_API_SSL_CA_CERT_PATH | SSL CA certificate path | 98 | | ssl-client-cert-path | string | DATA_API_SSL_CLIENT_CERT_PATH | SSL client certificate path | 99 | | ssl-client-key-path | string | DATA_API_SSL_CLIENT_KEY_PATH | SSL client private key path | 100 | | ssl-host-verification | string | DATA_API_SSL_HOST_VERIFICATION | Verify the peer certificate? It is highly insecure to disable host verification (default `true`) | 101 | | start-graphql | bool | DATA_API_START_GRAPHQL | Start the GraphQL endpoint (default `true`) | 102 | | graphql-path | string | DATA_API_GRAPHQL_PATH | GraphQL endpoint path (default `"/graphql"`) | 103 | | graphql-port | int | DATA_API_GRAPHQL_PORT | GraphQL endpoint port (default `8080`) | 104 | | graphql-schema-path | string | DATA_API_GRAPHQL_SCHEMA_PATH | GraphQL schema management path (default `"/graphql-schema"`) | 105 | 106 | #### Configuration Types 107 | 108 | The `strings` type expects a comma-delimited list e.g. `127.0.0.1, 127.0.0.2, 109 | 127.0.0.3` when using environment variables or a command flag, and it expects 110 | an array type when using a configuration file. 111 | 112 | YAML: 113 | 114 | ```yaml 115 | --- 116 | host: 117 | - "127.0.0.1" 118 | - "127.0.0.2" 119 | - "127.0.0.3" 120 | 121 | ``` 122 | 123 | JSON: 124 | ```json 125 | { 126 | "hosts": ["127.0.0.1", "127.0.0.2", "127.0.0.3"] 127 | } 128 | ``` 129 | 130 | #### Schema Management Operations 131 | 132 | | Operation | Allows | 133 | | --- | --- | 134 | | `TableCreate` | Creation of tables | 135 | | `TableDrop` | Removal of tables | 136 | | `TableAlterAdd` | Add new table columns | 137 | | `TableAlterDrop` | Remove table columns | 138 | | `KeyspaceCreate` | Creation of keyspaces | 139 | | `KeyspaceDrop` | Removal of keyspaces | 140 | 141 | #### TLS/SSL 142 | 143 | ##### HTTPS 144 | 145 | The API endpoint does not currently support HTTPS natively, but it can be handled by a gateway or 146 | reverse proxy. More information about protecting the API endpoint can be found in this 147 | [documentation][protecting]. 148 | 149 | ##### Client-to-node Encryption 150 | 151 | By default, traffic between the API endpoints and the database servers is not encrypted. To secure 152 | this traffic you will need to generate SSL certificates and enable SSL on the database servers. More 153 | information about enabling SSL (client-to-node encryption) on the database servers can be found in 154 | this [documentation][client-to-node]. After SSL is enabled on the database servers use the 155 | `ssl-enabled` option, along with `ssl-ca-cert-path` to enable secure connections. `ssl-ca-cert-path` 156 | is a path to the chain of certificates used to generate the database server's certificates. The 157 | certificate chain is used by API endpoints to verify the database server's certificates. The 158 | `ssl-client-cert-path` and `ssl-client-key-path` options are not required, but can be use to provide 159 | client-side certificates that are used by the database servers to authenticate and verify the API 160 | servers, this is known as mutual authentication. 161 | 162 | ## Building 163 | 164 | This section is mostly for developers. Pre-built docker image recommended. 165 | 166 | ### Building the Docker Image 167 | 168 | ```bash 169 | cd /cassandra-data-apis 170 | docker build -t cassandra-data-apis . 171 | ``` 172 | 173 | ### Run locally with single node, local Cassandra cluster 174 | 175 | ```bash 176 | cd /cassandra-data-apis 177 | docker build -t cassandra-data-apis . 178 | 179 | # On Linux (with a cluster started on the docker bridge: 172.17.0.1) 180 | docker run -p 8080:8080 -e "DATA_API_HOSTS=172.17.0.1" cassandra-data-apis 181 | 182 | # With a cluster bound to 0.0.0.0 183 | docker run --network host -e "DATA_API_HOSTS=127.0.0.1" cassandra-data-apis 184 | 185 | # On macOS (with a cluster bound to 0.0.0.0) 186 | docker run -p 8080:8080 -e "DATA_API_HOSTS=host.docker.internal" cassandra-data-apis 187 | ``` 188 | 189 | These host values can also be used in the configuration file approach used in 190 | the previous section. 191 | 192 | ### Build and run as a standalone webserver 193 | 194 | If you want to run this module as a standalone webserver, use: 195 | 196 | ```bash 197 | # Define the keyspace you want to use 198 | # Start the webserver 199 | go build run.exe && ./run.exe --hosts 127.0.0.1 --keyspace store 200 | ``` 201 | 202 | Your settings can be persisted using a configuration file: 203 | 204 | ```yaml 205 | hosts: 206 | - 127.0.0.1 207 | keyspace: store 208 | operations: 209 | - TableCreate 210 | - KeyspaceCreate 211 | port: 8080 212 | schema-update-interval: 30s 213 | ``` 214 | 215 | To start the server using a configuration file, use: 216 | 217 | ```bash 218 | ./run.exe --config .yaml 219 | ``` 220 | 221 | Settings can also be overridden using environment variables prefixed with 222 | `DATA_API_`: 223 | 224 | ```bash 225 | DATA_API_HOSTS=127.0.0.1 DATA_API_KEYSPACE=store ./run.exe --config .yaml 226 | ``` 227 | 228 | ### Plugin the routes within your HTTP request router 229 | 230 | #### Installation 231 | 232 | ``` 233 | go get github.com/datastax/cassandra-data-apis 234 | ``` 235 | 236 | #### Using the API 237 | 238 | To add the routes to your existing HTTP request router, use: 239 | 240 | ```go 241 | cfg := endpoint.NewEndpointConfig("your.first.contact.point", "your.second.contact.point") 242 | // Setup config here using your env variables 243 | endpoint, err := cfg.NewEndpoint() 244 | if err != nil { 245 | log.Fatalf("unable create new endpoint: %s", err) 246 | } 247 | keyspace := "store" 248 | routes, err = endpoint.RoutesKeyspaceGraphQL("/graphql", keyspace) 249 | // Setup routes on your http router 250 | ``` 251 | 252 | ## License 253 | 254 | © DataStax, Inc. 255 | 256 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with 257 | the License. You may obtain a copy of the License at 258 | 259 | http://www.apache.org/licenses/LICENSE-2.0 260 | 261 | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on 262 | an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the 263 | specific language governing permissions and limitations under the License. 264 | 265 | [protecting]: /docs/protecting/README.md 266 | [client-to-node]: https://docs.datastax.com/en/cassandra-oss/3.x/cassandra/configuration/secureSSLClientToNode.html 267 | -------------------------------------------------------------------------------- /graphql/schema.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "fmt" 5 | "github.com/datastax/cassandra-data-apis/auth" 6 | "github.com/datastax/cassandra-data-apis/config" 7 | "github.com/datastax/cassandra-data-apis/db" 8 | "github.com/datastax/cassandra-data-apis/log" 9 | "github.com/gocql/gocql" 10 | "github.com/graphql-go/graphql" 11 | ) 12 | 13 | const ( 14 | insertPrefix = "insert" 15 | deletePrefix = "delete" 16 | updatePrefix = "update" 17 | ) 18 | 19 | type SchemaGenerator struct { 20 | dbClient *db.Db 21 | namingFn config.NamingConventionFn 22 | useUserOrRoleAuth bool 23 | ksExcluded map[string]bool 24 | logger log.Logger 25 | } 26 | 27 | func NewSchemaGenerator(dbClient *db.Db, cfg config.Config) *SchemaGenerator { 28 | ksExcluded := map[string]bool{} 29 | for _, ksName := range config.SystemKeyspaces { 30 | ksExcluded[ksName] = true 31 | } 32 | for _, ksName := range cfg.ExcludedKeyspaces() { 33 | ksExcluded[ksName] = true 34 | } 35 | return &SchemaGenerator{ 36 | dbClient: dbClient, 37 | namingFn: cfg.Naming(), 38 | useUserOrRoleAuth: cfg.UseUserOrRoleAuth(), 39 | ksExcluded: ksExcluded, 40 | logger: cfg.Logger(), 41 | } 42 | } 43 | 44 | func (sg *SchemaGenerator) buildQueriesFields( 45 | ksSchema *KeyspaceGraphQLSchema, 46 | keyspace *gocql.KeyspaceMetadata, 47 | ) graphql.Fields { 48 | fields := graphql.Fields{} 49 | for _, table := range keyspace.Tables { 50 | if ksSchema.ignoredTables[table.Name] { 51 | continue 52 | } 53 | 54 | fields[ksSchema.naming.ToGraphQLOperation("", table.Name)] = &graphql.Field{ 55 | Description: fmt.Sprintf("Retrieves data from '%s' table using the equality operator.\n", table.Name) + 56 | "The amount of values contained in the result is limited by the page size " + 57 | fmt.Sprintf(" (defaults to %d). Use the pageState included in the result to ", config.DefaultPageSize) + 58 | "obtain the following rows.\n" + 59 | "When no fields are provided, it returns all rows in the table, limited by the page size.", 60 | Type: ksSchema.resultSelectTypes[table.Name], 61 | Args: graphql.FieldConfigArgument{ 62 | "value": {Type: ksSchema.tableScalarInputTypes[table.Name]}, 63 | "orderBy": {Type: graphql.NewList(ksSchema.orderEnums[table.Name])}, 64 | "options": {Type: inputQueryOptions, DefaultValue: inputQueryOptionsDefault}, 65 | }, 66 | Resolve: sg.queryFieldResolver(table, ksSchema, false), 67 | } 68 | 69 | fields[ksSchema.naming.ToGraphQLOperation("", table.Name)+"Filter"] = &graphql.Field{ 70 | Description: fmt.Sprintf("Retrieves data from '%s' table using equality \n", table.Name) + 71 | "and non-equality operators.\n" + 72 | "The amount of values contained in the result is limited by the page size " + 73 | fmt.Sprintf(" (defaults to %d). Use the pageState included in the result to ", config.DefaultPageSize) + 74 | "obtain the following rows.\n", 75 | Type: ksSchema.resultSelectTypes[table.Name], 76 | Args: graphql.FieldConfigArgument{ 77 | "filter": {Type: graphql.NewNonNull(ksSchema.tableOperatorInputTypes[table.Name])}, 78 | "orderBy": {Type: graphql.NewList(ksSchema.orderEnums[table.Name])}, 79 | "options": {Type: inputQueryOptions, DefaultValue: inputQueryOptionsDefault}, 80 | }, 81 | Resolve: sg.queryFieldResolver(table, ksSchema, true), 82 | } 83 | } 84 | 85 | if len(keyspace.Tables) == 0 || len(keyspace.Tables) == len(ksSchema.ignoredTables) { 86 | // graphql-go requires at least a single query and a single mutation 87 | fields["__keyspaceEmptyQuery"] = &graphql.Field{ 88 | Description: "Placeholder query that is exposed when a keyspace is empty.", 89 | Type: graphql.Boolean, 90 | Resolve: func(params graphql.ResolveParams) (interface{}, error) { 91 | return true, nil 92 | }, 93 | } 94 | } 95 | 96 | return fields 97 | } 98 | 99 | func (sg *SchemaGenerator) buildQuery( 100 | schema *KeyspaceGraphQLSchema, 101 | keyspace *gocql.KeyspaceMetadata, 102 | ) *graphql.Object { 103 | return graphql.NewObject( 104 | graphql.ObjectConfig{ 105 | Name: "Query", 106 | Fields: sg.buildQueriesFields(schema, keyspace), 107 | }) 108 | } 109 | 110 | func (sg *SchemaGenerator) buildMutationFields( 111 | ksSchema *KeyspaceGraphQLSchema, 112 | keyspace *gocql.KeyspaceMetadata, 113 | views map[string]bool, 114 | ) graphql.Fields { 115 | fields := graphql.Fields{} 116 | for name, table := range keyspace.Tables { 117 | if ksSchema.ignoredTables[table.Name] || views[name] { 118 | continue 119 | } 120 | 121 | fields[ksSchema.naming.ToGraphQLOperation(insertPrefix, name)] = &graphql.Field{ 122 | Description: fmt.Sprintf("Inserts an entire row or upserts data into an existing row of '%s' table. ", table.Name) + 123 | "Requires a value for each component of the primary key, but not for any other columns. " + 124 | "Missing values are left unset.", 125 | Type: ksSchema.resultUpdateTypes[table.Name], 126 | Args: graphql.FieldConfigArgument{ 127 | "value": {Type: graphql.NewNonNull(ksSchema.tableScalarInputTypes[table.Name])}, 128 | "ifNotExists": {Type: graphql.Boolean}, 129 | "options": {Type: inputMutationOptions, DefaultValue: inputMutationOptionsDefault}, 130 | }, 131 | Resolve: sg.mutationFieldResolver(table, ksSchema, insertOperation), 132 | } 133 | 134 | fields[ksSchema.naming.ToGraphQLOperation(deletePrefix, name)] = &graphql.Field{ 135 | Description: fmt.Sprintf("Removes an entire row in '%s' table.", table.Name), 136 | Type: ksSchema.resultUpdateTypes[table.Name], 137 | Args: graphql.FieldConfigArgument{ 138 | "value": {Type: graphql.NewNonNull(ksSchema.tableScalarInputTypes[table.Name])}, 139 | "ifExists": {Type: graphql.Boolean}, 140 | "ifCondition": {Type: ksSchema.tableOperatorInputTypes[table.Name]}, 141 | "options": {Type: inputMutationOptions, DefaultValue: inputMutationOptionsDefault}, 142 | }, 143 | Resolve: sg.mutationFieldResolver(table, ksSchema, deleteOperation), 144 | } 145 | 146 | fields[ksSchema.naming.ToGraphQLOperation(updatePrefix, name)] = &graphql.Field{ 147 | Description: fmt.Sprintf("Updates one or more column values to a row in '%s' table.", table.Name) + 148 | "Like the insert operation, update is an upsert operation: if the specified row does not exist," + 149 | "the command creates it.", 150 | Type: ksSchema.resultUpdateTypes[table.Name], 151 | Args: graphql.FieldConfigArgument{ 152 | "value": {Type: graphql.NewNonNull(ksSchema.tableScalarInputTypes[table.Name])}, 153 | "ifExists": {Type: graphql.Boolean}, 154 | "ifCondition": {Type: ksSchema.tableOperatorInputTypes[table.Name]}, 155 | "options": {Type: inputMutationOptions, DefaultValue: inputMutationOptionsDefault}, 156 | }, 157 | Resolve: sg.mutationFieldResolver(table, ksSchema, updateOperation), 158 | } 159 | } 160 | 161 | if len(keyspace.Tables) == 0 || len(keyspace.Tables) == len(ksSchema.ignoredTables) { 162 | // graphql-go requires at least a single query and a single mutation 163 | fields["__keyspaceEmptyMutation"] = &graphql.Field{ 164 | Description: "Placeholder mutation that is exposed when a keyspace is empty.", 165 | Type: graphql.Boolean, 166 | Resolve: func(params graphql.ResolveParams) (interface{}, error) { 167 | return true, nil 168 | }, 169 | } 170 | } 171 | 172 | return fields 173 | } 174 | 175 | func (sg *SchemaGenerator) buildMutation( 176 | schema *KeyspaceGraphQLSchema, 177 | keyspace *gocql.KeyspaceMetadata, 178 | views map[string]bool, 179 | ) *graphql.Object { 180 | return graphql.NewObject( 181 | graphql.ObjectConfig{ 182 | Name: "Mutation", 183 | Fields: sg.buildMutationFields(schema, keyspace, views), 184 | }) 185 | } 186 | 187 | func (sg *SchemaGenerator) BuildSchemas(singleKeyspace string) (map[string]*graphql.Schema, error) { 188 | if singleKeyspace != "" { 189 | sg.logger.Info("building schema", "keyspace", singleKeyspace) 190 | // Schema generator is only focused on a single keyspace 191 | if schema, err := sg.buildSchema(singleKeyspace); err != nil { 192 | return nil, err 193 | } else { 194 | return map[string]*graphql.Schema{singleKeyspace: &schema}, nil 195 | } 196 | } 197 | 198 | keyspaces, err := sg.dbClient.Keyspaces("") 199 | if err != nil { 200 | return nil, err 201 | } 202 | 203 | sg.logger.Info("building schemas") 204 | result := make(map[string]*graphql.Schema, len(keyspaces)) 205 | builtKeyspaces := make([]string, 0, len(keyspaces)) 206 | for _, ksName := range keyspaces { 207 | if sg.isKeyspaceExcluded(ksName) { 208 | continue 209 | } 210 | schema, err := sg.buildSchema(ksName) 211 | if err != nil { 212 | return nil, err 213 | } 214 | 215 | result[ksName] = &schema 216 | builtKeyspaces = append(builtKeyspaces, ksName) 217 | } 218 | 219 | if len(builtKeyspaces) > 0 { 220 | sg.logger.Info("built keyspace schemas", "keyspaces", builtKeyspaces) 221 | } 222 | 223 | return result, nil 224 | } 225 | 226 | // Build GraphQL schema for tables in the provided keyspace metadata 227 | func (sg *SchemaGenerator) buildSchema(keyspaceName string) (graphql.Schema, error) { 228 | keyspace, err := sg.dbClient.Keyspace(keyspaceName) 229 | if err != nil { 230 | return graphql.Schema{}, err 231 | } 232 | 233 | views, err := sg.dbClient.Views(keyspaceName) // Used to exclude views from mutations 234 | if err != nil { 235 | return graphql.Schema{}, err 236 | } 237 | 238 | ksNaming := sg.dbClient.KeyspaceNamingInfo(keyspace) 239 | keyspaceSchema := &KeyspaceGraphQLSchema{ 240 | ignoredTables: make(map[string]bool), 241 | schemaGen: sg, 242 | naming: sg.namingFn(ksNaming), 243 | } 244 | 245 | if err := keyspaceSchema.BuildTypes(keyspace); err != nil { 246 | return graphql.Schema{}, err 247 | } 248 | 249 | return graphql.NewSchema( 250 | graphql.SchemaConfig{ 251 | Query: sg.buildQuery(keyspaceSchema, keyspace), 252 | Mutation: sg.buildMutation(keyspaceSchema, keyspace, views), 253 | }, 254 | ) 255 | } 256 | 257 | func (sg *SchemaGenerator) isKeyspaceExcluded(ksName string) bool { 258 | return sg.ksExcluded[ksName] 259 | } 260 | 261 | func (sg *SchemaGenerator) checkUserOrRoleAuth(params graphql.ResolveParams) (string, error) { 262 | if sg.useUserOrRoleAuth { 263 | value := auth.ContextUserOrRole(params.Context) 264 | if value == "" { 265 | return "", fmt.Errorf("expected user or role for this operation") 266 | } 267 | return value, nil 268 | } 269 | return "", nil 270 | } 271 | -------------------------------------------------------------------------------- /db/query_generators_test.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "github.com/datastax/cassandra-data-apis/internal/testutil" 5 | "github.com/datastax/cassandra-data-apis/types" 6 | "github.com/gocql/gocql" 7 | . "github.com/onsi/ginkgo" 8 | . "github.com/onsi/gomega" 9 | "github.com/stretchr/testify/mock" 10 | "testing" 11 | ) 12 | 13 | var _ = Describe("db", func() { 14 | Describe("Delete", func() { 15 | items := []struct { 16 | description string 17 | columnNames []string 18 | queryParams []interface{} 19 | query string 20 | ifExists bool 21 | ifCondition []types.ConditionItem 22 | }{ 23 | { 24 | "a single column", 25 | []string{"a"}, []interface{}{"b"}, `DELETE FROM "ks1"."tbl1" WHERE "a" = ?`, false, nil}, 26 | { 27 | "multiple columns", 28 | []string{"A", "b"}, 29 | []interface{}{"A Value", 2}, `DELETE FROM "ks1"."tbl1" WHERE "A" = ? AND "b" = ?`, false, nil}, 30 | { 31 | "IF EXISTS", 32 | []string{"a"}, []interface{}{"b"}, `DELETE FROM "ks1"."tbl1" WHERE "a" = ? IF EXISTS`, true, nil}, 33 | { 34 | "IF condition", 35 | []string{"a"}, []interface{}{"b"}, 36 | `DELETE FROM "ks1"."tbl1" WHERE "a" = ? IF "C" = ?`, false, []types.ConditionItem{{"C", "=", "z"}}}, 37 | } 38 | 39 | for i := 0; i < len(items); i++ { 40 | // Capture the item in the closure 41 | item := items[i] 42 | 43 | It("Should generate DELETE statement with "+item.description, func() { 44 | sessionMock := SessionMock{} 45 | sessionMock.On("ExecuteIter", mock.Anything, mock.Anything, mock.Anything).Return(ResultMock{}, nil) 46 | db := &Db{ 47 | session: &sessionMock, 48 | } 49 | 50 | _, err := db.Delete(&DeleteInfo{ 51 | Keyspace: "ks1", 52 | Table: "tbl1", 53 | Columns: item.columnNames, 54 | QueryParams: item.queryParams, 55 | IfExists: item.ifExists, 56 | IfCondition: item.ifCondition, 57 | }, nil) 58 | Expect(err).NotTo(HaveOccurred()) 59 | 60 | expectedQueryParams := make([]interface{}, len(item.queryParams)) 61 | copy(expectedQueryParams, item.queryParams) 62 | 63 | if len(item.ifCondition) > 0 { 64 | for _, condition := range item.ifCondition { 65 | expectedQueryParams = append(expectedQueryParams, condition.Value) 66 | } 67 | } 68 | sessionMock.AssertCalled(GinkgoT(), "ExecuteIter", item.query, mock.Anything, expectedQueryParams) 69 | sessionMock.AssertExpectations(GinkgoT()) 70 | }) 71 | } 72 | }) 73 | 74 | Describe("Update", func() { 75 | items := []struct { 76 | description string 77 | columnNames []string 78 | queryParams []interface{} 79 | ifExists bool 80 | ifCondition []types.ConditionItem 81 | ttl int 82 | query string 83 | expectedParams []interface{} 84 | }{ 85 | { 86 | "multiple set columns", 87 | []string{"CK1", "a", "b", "pk2", "pk1"}, []interface{}{1, 2, 3, 4, 5}, false, nil, -1, 88 | `UPDATE "ks1"."tbl1" SET "a" = ?, "b" = ? WHERE "CK1" = ? AND "pk2" = ? AND "pk1" = ?`, 89 | []interface{}{2, 3, 1, 4, 5}}, 90 | { 91 | "ttl and IF EXISTS", 92 | []string{"a", "CK1", "pk1", "pk2"}, []interface{}{1, 2, 3, 4}, true, nil, 60, 93 | `UPDATE "ks1"."tbl1" USING TTL ? SET "a" = ? WHERE "CK1" = ? AND "pk1" = ? AND "pk2" = ? IF EXISTS`, 94 | []interface{}{60, 1, 2, 3, 4}}, 95 | { 96 | "IF condition", 97 | []string{"a", "CK1", "pk1", "pk2"}, []interface{}{1, 2, 3, 4}, false, 98 | []types.ConditionItem{{"c", ">", 100}}, -1, 99 | `UPDATE "ks1"."tbl1" SET "a" = ? WHERE "CK1" = ? AND "pk1" = ? AND "pk2" = ? IF "c" > ?`, 100 | []interface{}{1, 2, 3, 4, 100}}, 101 | } 102 | 103 | for i := 0; i < len(items); i++ { 104 | // Capture the item in the closure 105 | item := items[i] 106 | 107 | It("Should generate UPDATE statement with "+item.description, func() { 108 | table := &gocql.TableMetadata{ 109 | Name: "tbl1", 110 | Columns: map[string]*gocql.ColumnMetadata{ 111 | "pk1": {Name: "pk1", Kind: gocql.ColumnPartitionKey}, 112 | "pk2": {Name: "pk2", Kind: gocql.ColumnPartitionKey}, 113 | "CK1": {Name: "CK1", Kind: gocql.ColumnClusteringKey}, 114 | }, 115 | } 116 | table.PartitionKey = createKey(table.Columns, gocql.ColumnPartitionKey) 117 | table.ClusteringColumns = createKey(table.Columns, gocql.ColumnClusteringKey) 118 | sessionMock := SessionMock{} 119 | sessionMock.On("ExecuteIter", mock.Anything, mock.Anything, mock.Anything).Return(ResultMock{}, nil) 120 | db := &Db{ 121 | session: &sessionMock, 122 | } 123 | 124 | _, err := db.Update(&UpdateInfo{ 125 | Keyspace: "ks1", 126 | Table: table, 127 | Columns: item.columnNames, 128 | QueryParams: item.queryParams, 129 | IfExists: item.ifExists, 130 | IfCondition: item.ifCondition, 131 | TTL: item.ttl, 132 | }, nil) 133 | Expect(err).NotTo(HaveOccurred()) 134 | sessionMock.AssertCalled(GinkgoT(), "ExecuteIter", item.query, mock.Anything, item.expectedParams) 135 | sessionMock.AssertExpectations(GinkgoT()) 136 | }) 137 | } 138 | }) 139 | 140 | Describe("Insert", func() { 141 | items := []struct { 142 | description string 143 | columnNames []string 144 | queryParams []interface{} 145 | ttl int 146 | ifNotExists bool 147 | query string 148 | }{ 149 | { 150 | "a single column", 151 | []string{"a"}, []interface{}{100}, -1, false, 152 | `INSERT INTO "ks1"."tbl1" ("a") VALUES (?)`}, 153 | { 154 | "multiple columns", 155 | 156 | []string{"a", "B"}, []interface{}{100, 2}, -1, false, 157 | `INSERT INTO "ks1"."tbl1" ("a", "B") VALUES (?, ?)`}, 158 | { 159 | "IF NOT EXISTS", 160 | []string{"a"}, []interface{}{100}, -1, true, 161 | `INSERT INTO "ks1"."tbl1" ("a") VALUES (?) IF NOT EXISTS`}, 162 | { 163 | "TTL", 164 | []string{"a"}, []interface{}{"z"}, 3600, true, 165 | `INSERT INTO "ks1"."tbl1" ("a") VALUES (?) IF NOT EXISTS USING TTL ?`}, 166 | } 167 | 168 | for i := 0; i < len(items); i++ { 169 | // Capture the item in the closure 170 | item := items[i] 171 | It("Should generate INSERT statement with "+item.description, func() { 172 | sessionMock := SessionMock{} 173 | sessionMock.On("ExecuteIter", mock.Anything, mock.Anything, mock.Anything).Return(ResultMock{}, nil) 174 | db := &Db{ 175 | session: &sessionMock, 176 | } 177 | 178 | expectedQueryParams := make([]interface{}, len(item.queryParams)) 179 | copy(expectedQueryParams, item.queryParams) 180 | 181 | if item.ttl >= 0 { 182 | expectedQueryParams = append(expectedQueryParams, item.ttl) 183 | } 184 | 185 | _, err := db.Insert(&InsertInfo{ 186 | Keyspace: "ks1", 187 | Table: "tbl1", 188 | Columns: item.columnNames, 189 | QueryParams: item.queryParams, 190 | TTL: item.ttl, 191 | IfNotExists: item.ifNotExists, 192 | }, nil) 193 | 194 | Expect(err).NotTo(HaveOccurred()) 195 | sessionMock.AssertCalled(GinkgoT(), "ExecuteIter", item.query, mock.Anything, expectedQueryParams) 196 | sessionMock.AssertExpectations(GinkgoT()) 197 | }) 198 | } 199 | }) 200 | 201 | Describe("Select", func() { 202 | items := []struct { 203 | description string 204 | where []types.ConditionItem 205 | options *types.QueryOptions 206 | orderBy []ColumnOrder 207 | columns []string 208 | query string 209 | }{ 210 | {"a single condition", []types.ConditionItem{{"a", "=", 1}}, &types.QueryOptions{}, nil, nil, 211 | `SELECT * FROM "ks1"."tbl1" WHERE "a" = ?`}, 212 | {"condition and one select column", []types.ConditionItem{{"a", "=", 1}}, &types.QueryOptions{}, nil, 213 | []string{"col1"}, 214 | `SELECT "col1" FROM "ks1"."tbl1" WHERE "a" = ?`}, 215 | {"condition and select columns", []types.ConditionItem{{"col1", "=", 1}}, &types.QueryOptions{}, nil, 216 | []string{"COL2", "col1"}, 217 | `SELECT "COL2", "col1" FROM "ks1"."tbl1" WHERE "col1" = ?`}, 218 | {"no where clause", []types.ConditionItem{}, &types.QueryOptions{}, nil, nil, 219 | `SELECT * FROM "ks1"."tbl1"`}, 220 | {"no where clause and limit", []types.ConditionItem{}, &types.QueryOptions{Limit: 1}, nil, nil, 221 | `SELECT * FROM "ks1"."tbl1" LIMIT ?`}, 222 | {"multiple conditions", []types.ConditionItem{{"a", "=", 1}, {"B", ">", 2}}, &types.QueryOptions{}, nil, nil, 223 | `SELECT * FROM "ks1"."tbl1" WHERE "a" = ? AND "B" > ?`}, 224 | {"relational operators", []types.ConditionItem{{"a", "=", 1}, {"b", ">", 2}, {"b", "<=", 5}}, 225 | &types.QueryOptions{}, nil, nil, `SELECT * FROM "ks1"."tbl1" WHERE "a" = ? AND "b" > ? AND "b" <= ?`}, 226 | {"order clause", []types.ConditionItem{{"a", "=", 1}}, 227 | &types.QueryOptions{}, []ColumnOrder{{"c", "DESC"}}, nil, 228 | `SELECT * FROM "ks1"."tbl1" WHERE "a" = ? ORDER BY "c" DESC`}, 229 | {"order and limit", []types.ConditionItem{{"ABC", "=", "z"}}, &types.QueryOptions{Limit: 1}, 230 | []ColumnOrder{{"DEF", "ASC"}}, nil, 231 | `SELECT * FROM "ks1"."tbl1" WHERE "ABC" = ? ORDER BY "DEF" ASC LIMIT ?`}, 232 | } 233 | 234 | for i := 0; i < len(items); i++ { 235 | // Capture the item in the closure 236 | item := items[i] 237 | 238 | It("Should generate SELECT statement with "+item.description, func() { 239 | resultMock := &ResultMock{} 240 | resultMock. 241 | On("PageState").Return([]byte{}). 242 | On("Values").Return([]map[string]interface{}{}, nil) 243 | sessionMock := SessionMock{} 244 | sessionMock.On("ExecuteIter", mock.Anything, mock.Anything, mock.Anything).Return(resultMock, nil) 245 | db := &Db{ 246 | session: &sessionMock, 247 | } 248 | 249 | queryParams := make([]interface{}, 0) 250 | for _, v := range item.where { 251 | queryParams = append(queryParams, v.Value) 252 | } 253 | 254 | if item.options != nil && item.options.Limit > 0 { 255 | queryParams = append(queryParams, item.options.Limit) 256 | } 257 | 258 | _, err := db.Select(&SelectInfo{ 259 | Keyspace: "ks1", 260 | Table: "tbl1", 261 | Columns: item.columns, 262 | Where: item.where, 263 | Options: item.options, 264 | OrderBy: item.orderBy, 265 | }, nil) 266 | Expect(err).NotTo(HaveOccurred()) 267 | sessionMock.AssertCalled(GinkgoT(), "ExecuteIter", item.query, mock.Anything, queryParams) 268 | sessionMock.AssertExpectations(GinkgoT()) 269 | }) 270 | } 271 | }) 272 | }) 273 | 274 | func TestTypeMapping(t *testing.T) { 275 | RegisterFailHandler(Fail) 276 | RunSpecs(t, "Db test suite") 277 | } 278 | 279 | var _ = BeforeSuite(testutil.BeforeTestSuite) 280 | 281 | var _ = AfterSuite(testutil.AfterTestSuite) 282 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS --------------------------------------------------------------------------------