mirror of
https://github.com/mainflux/mainflux.git
synced 2025-04-28 13:48:49 +08:00
MF-313 - Implement basic Cassandra reader (#331)
* Fix logger message in http service Signed-off-by: Aleksandar Novakovic <aleksandar.novakovic@mainflux.com> * Inline query and error handling in cassandra writer Signed-off-by: Aleksandar Novakovic <aleksandar.novakovic@mainflux.com> * Fix comments and import statement in writer interface Signed-off-by: Aleksandar Novakovic <aleksandar.novakovic@mainflux.com> * Add reader common interface and shared HTTP API Signed-off-by: Aleksandar Novakovic <aleksandar.novakovic@mainflux.com> * Add Cassandra reader implementation Signed-off-by: Aleksandar Novakovic <aleksandar.novakovic@mainflux.com> * Add tests for cassandra reader Signed-off-by: Aleksandar Novakovic <aleksandar.novakovic@mainflux.com> * Add swagger doc and readme for readers Signed-off-by: Aleksandar Novakovic <aleksandar.novakovic@mainflux.com> * Update make file Signed-off-by: Aleksandar Novakovic <aleksandar.novakovic@mainflux.com> * Add docker-compose configuration for cassandra reader Signed-off-by: Aleksandar Novakovic <aleksandar.novakovic@mainflux.com> * Add readme file to cassandra reader Signed-off-by: Aleksandar Novakovic <aleksandar.novakovic@mainflux.com>
This commit is contained in:
parent
29d146636a
commit
6d48446c16
2
Makefile
2
Makefile
@ -1,5 +1,5 @@
|
|||||||
BUILD_DIR = build
|
BUILD_DIR = build
|
||||||
SERVICES = users things http normalizer ws influxdb mongodb cassandra
|
SERVICES = users things http normalizer ws influxdb mongodb cassandra-writer cassandra-reader
|
||||||
DOCKERS = $(addprefix docker_,$(SERVICES))
|
DOCKERS = $(addprefix docker_,$(SERVICES))
|
||||||
CGO_ENABLED ?= 0
|
CGO_ENABLED ?= 0
|
||||||
GOOS ?= linux
|
GOOS ?= linux
|
||||||
|
127
cmd/cassandra-reader/main.go
Normal file
127
cmd/cassandra-reader/main.go
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
kitprometheus "github.com/go-kit/kit/metrics/prometheus"
|
||||||
|
"github.com/gocql/gocql"
|
||||||
|
"github.com/mainflux/mainflux"
|
||||||
|
log "github.com/mainflux/mainflux/logger"
|
||||||
|
"github.com/mainflux/mainflux/readers"
|
||||||
|
"github.com/mainflux/mainflux/readers/api"
|
||||||
|
"github.com/mainflux/mainflux/readers/cassandra"
|
||||||
|
thingsapi "github.com/mainflux/mainflux/things/api/grpc"
|
||||||
|
stdprometheus "github.com/prometheus/client_golang/prometheus"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sep = ","
|
||||||
|
|
||||||
|
defPort = "8180"
|
||||||
|
defCluster = "127.0.0.1"
|
||||||
|
defKeyspace = "mainflux"
|
||||||
|
defThingsURL = "localhost:8181"
|
||||||
|
|
||||||
|
envPort = "MF_CASSANDRA_READER_PORT"
|
||||||
|
envCluster = "MF_CASSANDRA_READER_DB_CLUSTER"
|
||||||
|
envKeyspace = "MF_CASSANDRA_READER_DB_KEYSPACE"
|
||||||
|
envThingsURL = "MF_THINGS_URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
port string
|
||||||
|
cluster string
|
||||||
|
keyspace string
|
||||||
|
thingsURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := loadConfig()
|
||||||
|
|
||||||
|
logger := log.New(os.Stdout)
|
||||||
|
|
||||||
|
session := connectToCassandra(cfg.cluster, cfg.keyspace, logger)
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
conn := connectToThings(cfg.thingsURL, logger)
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
tc := thingsapi.NewClient(conn)
|
||||||
|
repo := newService(session, logger)
|
||||||
|
|
||||||
|
errs := make(chan error, 2)
|
||||||
|
|
||||||
|
go startHTTPServer(repo, tc, cfg.port, errs, logger)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
c := make(chan os.Signal)
|
||||||
|
signal.Notify(c, syscall.SIGINT)
|
||||||
|
errs <- fmt.Errorf("%s", <-c)
|
||||||
|
}()
|
||||||
|
|
||||||
|
err := <-errs
|
||||||
|
logger.Error(fmt.Sprintf("Cassandra writer service terminated: %s", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() config {
|
||||||
|
return config{
|
||||||
|
port: mainflux.Env(envPort, defPort),
|
||||||
|
cluster: mainflux.Env(envCluster, defCluster),
|
||||||
|
keyspace: mainflux.Env(envKeyspace, defKeyspace),
|
||||||
|
thingsURL: mainflux.Env(envThingsURL, defThingsURL),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectToCassandra(cluster, keyspace string, logger log.Logger) *gocql.Session {
|
||||||
|
session, err := cassandra.Connect(strings.Split(cluster, sep), keyspace)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(fmt.Sprintf("Failed to connect to Cassandra cluster: %s", err))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectToThings(url string, logger log.Logger) *grpc.ClientConn {
|
||||||
|
conn, err := grpc.Dial(url, grpc.WithInsecure())
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(fmt.Sprintf("Failed to connect to things service: %s", err))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func newService(session *gocql.Session, logger log.Logger) readers.MessageRepository {
|
||||||
|
repo := cassandra.New(session)
|
||||||
|
repo = api.LoggingMiddleware(repo, logger)
|
||||||
|
repo = api.MetricsMiddleware(
|
||||||
|
repo,
|
||||||
|
kitprometheus.NewCounterFrom(stdprometheus.CounterOpts{
|
||||||
|
Namespace: "cassandra",
|
||||||
|
Subsystem: "message_reader",
|
||||||
|
Name: "request_count",
|
||||||
|
Help: "Number of requests received.",
|
||||||
|
}, []string{"method"}),
|
||||||
|
kitprometheus.NewSummaryFrom(stdprometheus.SummaryOpts{
|
||||||
|
Namespace: "cassandra",
|
||||||
|
Subsystem: "message_reader",
|
||||||
|
Name: "request_latency_microseconds",
|
||||||
|
Help: "Total duration of requests in microseconds.",
|
||||||
|
}, []string{"method"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return repo
|
||||||
|
}
|
||||||
|
|
||||||
|
func startHTTPServer(repo readers.MessageRepository, tc mainflux.ThingsServiceClient, port string, errs chan error, logger log.Logger) {
|
||||||
|
p := fmt.Sprintf(":%s", port)
|
||||||
|
logger.Info(fmt.Sprintf("Cassandra writer service started, exposed port %s", port))
|
||||||
|
errs <- http.ListenAndServe(p, api.MakeHandler(repo, tc, "cassandra-reader"))
|
||||||
|
}
|
@ -52,7 +52,7 @@ func main() {
|
|||||||
|
|
||||||
conn, err := grpc.Dial(cfg.ThingsURL, grpc.WithInsecure())
|
conn, err := grpc.Dial(cfg.ThingsURL, grpc.WithInsecure())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error(fmt.Sprintf("Failed to connect to users service: %s", err))
|
logger.Error(fmt.Sprintf("Failed to connect to things service: %s", err))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
28
docker/addons/cassandra-reader/docker-compose.yml
Normal file
28
docker/addons/cassandra-reader/docker-compose.yml
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
###
|
||||||
|
# This docker-compose file contains optional Cassandra and cassandra-reader. Since these are optional, this file is
|
||||||
|
# dependent of docker-compose file from <project_root>/docker. In order to run
|
||||||
|
# these optional service, execute command:
|
||||||
|
# docker-compose -f docker/docker-compose.yml -f docker/addons/cassandra-reader/docker-compose.yml up
|
||||||
|
# from project root.
|
||||||
|
###
|
||||||
|
|
||||||
|
version: "3"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
docker_mainflux-base-net:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
services:
|
||||||
|
cassandra-reader:
|
||||||
|
image: mainflux/cassandra-reader:latest
|
||||||
|
container_name: mainflux-cassandra-reader
|
||||||
|
restart: on-failure
|
||||||
|
environment:
|
||||||
|
MF_THINGS_URL: things:8183
|
||||||
|
MF_CASSANDRA_READER_PORT: 8903
|
||||||
|
MF_CASSANDRA_READER_DB_CLUSTER: mainflux-cassandra
|
||||||
|
MF_CASSANDRA_READER_DB_KEYSPACE: mainflux
|
||||||
|
ports:
|
||||||
|
- 8903:8903
|
||||||
|
networks:
|
||||||
|
- docker_mainflux-base-net
|
@ -2,24 +2,29 @@
|
|||||||
# This docker-compose file contains optional Cassandra and cassandra-writer. Since these are optional, this file is
|
# This docker-compose file contains optional Cassandra and cassandra-writer. Since these are optional, this file is
|
||||||
# dependent of docker-compose file from <project_root>/docker. In order to run
|
# dependent of docker-compose file from <project_root>/docker. In order to run
|
||||||
# these optional service, execute command:
|
# these optional service, execute command:
|
||||||
# docker-compose -f docker/docker-compose.yml -f docker/addons/cassandra/docker-compose.yml up
|
# docker-compose -f docker/docker-compose.yml -f docker/addons/cassandra-writer/docker-compose.yml up
|
||||||
# from project root.
|
# from project root.
|
||||||
###
|
###
|
||||||
|
|
||||||
version: "3"
|
version: "3"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
docker_mainflux-base-net:
|
||||||
|
external: true
|
||||||
|
|
||||||
services:
|
services:
|
||||||
cassandra:
|
cassandra:
|
||||||
image: cassandra:3.11.2
|
image: cassandra:3.11.2
|
||||||
container_name: mainflux-cassandra
|
container_name: mainflux-cassandra
|
||||||
restart: on-failure
|
restart: on-failure
|
||||||
|
networks:
|
||||||
|
- docker_mainflux-base-net
|
||||||
|
|
||||||
cassandra-writer:
|
cassandra-writer:
|
||||||
image: mainflux/cassandra:latest
|
image: mainflux/cassandra-writer:latest
|
||||||
container_name: mainflux-cassandra-writer
|
container_name: mainflux-cassandra-writer
|
||||||
depends_on:
|
depends_on:
|
||||||
- cassandra
|
- cassandra
|
||||||
- nats
|
|
||||||
expose:
|
expose:
|
||||||
- 8902
|
- 8902
|
||||||
restart: on-failure
|
restart: on-failure
|
||||||
@ -30,3 +35,5 @@ services:
|
|||||||
MF_CASSANDRA_WRITER_DB_KEYSPACE: mainflux
|
MF_CASSANDRA_WRITER_DB_KEYSPACE: mainflux
|
||||||
ports:
|
ports:
|
||||||
- 8902:8902
|
- 8902:8902
|
||||||
|
networks:
|
||||||
|
- docker_mainflux-base-net
|
@ -1,3 +1,3 @@
|
|||||||
docker-compose -f docker/docker-compose.yml -f docker/addons/cassandra/docker-compose.yml up -d
|
docker-compose -f docker/addons/cassandra-writer/docker-compose.yml up -d
|
||||||
sleep 20
|
sleep 20
|
||||||
docker exec mainflux-cassandra cqlsh -e "CREATE KEYSPACE IF NOT EXISTS mainflux WITH replication = {'class':'SimpleStrategy','replication_factor':'1'};"
|
docker exec mainflux-cassandra cqlsh -e "CREATE KEYSPACE IF NOT EXISTS mainflux WITH replication = {'class':'SimpleStrategy','replication_factor':'1'};"
|
@ -8,6 +8,10 @@
|
|||||||
|
|
||||||
version: "3"
|
version: "3"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
mainflux-base-net:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
services:
|
services:
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:1.13-alpine
|
image: nginx:1.13-alpine
|
||||||
@ -22,11 +26,15 @@ services:
|
|||||||
- 80:80
|
- 80:80
|
||||||
- 443:443
|
- 443:443
|
||||||
- 8883:8883
|
- 8883:8883
|
||||||
|
networks:
|
||||||
|
- mainflux-base-net
|
||||||
|
|
||||||
nats:
|
nats:
|
||||||
image: nats:1.1.0
|
image: nats:1.1.0
|
||||||
container_name: mainflux-nats
|
container_name: mainflux-nats
|
||||||
restart: on-failure
|
restart: on-failure
|
||||||
|
networks:
|
||||||
|
- mainflux-base-net
|
||||||
|
|
||||||
users-db:
|
users-db:
|
||||||
image: postgres:10.2-alpine
|
image: postgres:10.2-alpine
|
||||||
@ -36,6 +44,8 @@ services:
|
|||||||
POSTGRES_USER: mainflux
|
POSTGRES_USER: mainflux
|
||||||
POSTGRES_PASSWORD: mainflux
|
POSTGRES_PASSWORD: mainflux
|
||||||
POSTGRES_DB: users
|
POSTGRES_DB: users
|
||||||
|
networks:
|
||||||
|
- mainflux-base-net
|
||||||
|
|
||||||
users:
|
users:
|
||||||
image: mainflux/users:latest
|
image: mainflux/users:latest
|
||||||
@ -57,6 +67,8 @@ services:
|
|||||||
MF_USERS_SECRET: secret
|
MF_USERS_SECRET: secret
|
||||||
ports:
|
ports:
|
||||||
- 8180:8180
|
- 8180:8180
|
||||||
|
networks:
|
||||||
|
- mainflux-base-net
|
||||||
|
|
||||||
things-db:
|
things-db:
|
||||||
image: postgres:10.2-alpine
|
image: postgres:10.2-alpine
|
||||||
@ -66,6 +78,8 @@ services:
|
|||||||
POSTGRES_USER: mainflux
|
POSTGRES_USER: mainflux
|
||||||
POSTGRES_PASSWORD: mainflux
|
POSTGRES_PASSWORD: mainflux
|
||||||
POSTGRES_DB: things
|
POSTGRES_DB: things
|
||||||
|
networks:
|
||||||
|
- mainflux-base-net
|
||||||
|
|
||||||
things:
|
things:
|
||||||
image: mainflux/things:latest
|
image: mainflux/things:latest
|
||||||
@ -89,6 +103,8 @@ services:
|
|||||||
MF_THINGS_SECRET: secret
|
MF_THINGS_SECRET: secret
|
||||||
ports:
|
ports:
|
||||||
- 8182:8182
|
- 8182:8182
|
||||||
|
networks:
|
||||||
|
- mainflux-base-net
|
||||||
|
|
||||||
normalizer:
|
normalizer:
|
||||||
image: mainflux/normalizer:latest
|
image: mainflux/normalizer:latest
|
||||||
@ -101,6 +117,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
MF_NATS_URL: nats://nats:4222
|
MF_NATS_URL: nats://nats:4222
|
||||||
MF_NORMALIZER_PORT: 8184
|
MF_NORMALIZER_PORT: 8184
|
||||||
|
networks:
|
||||||
|
- mainflux-base-net
|
||||||
|
|
||||||
dashflux:
|
dashflux:
|
||||||
image: mainflux/dashflux:latest
|
image: mainflux/dashflux:latest
|
||||||
@ -108,6 +126,8 @@ services:
|
|||||||
restart: on-failure
|
restart: on-failure
|
||||||
ports:
|
ports:
|
||||||
- 3000:3000
|
- 3000:3000
|
||||||
|
networks:
|
||||||
|
- mainflux-base-net
|
||||||
|
|
||||||
ws-adapter:
|
ws-adapter:
|
||||||
image: mainflux/ws:latest
|
image: mainflux/ws:latest
|
||||||
@ -124,6 +144,8 @@ services:
|
|||||||
MF_THINGS_URL: things:8183
|
MF_THINGS_URL: things:8183
|
||||||
ports:
|
ports:
|
||||||
- 8186:8186
|
- 8186:8186
|
||||||
|
networks:
|
||||||
|
- mainflux-base-net
|
||||||
|
|
||||||
http-adapter:
|
http-adapter:
|
||||||
image: mainflux/http:latest
|
image: mainflux/http:latest
|
||||||
@ -140,11 +162,15 @@ services:
|
|||||||
MF_THINGS_URL: things:8183
|
MF_THINGS_URL: things:8183
|
||||||
ports:
|
ports:
|
||||||
- 8185:8185
|
- 8185:8185
|
||||||
|
networks:
|
||||||
|
- mainflux-base-net
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:4.0.9-alpine
|
image: redis:4.0.9-alpine
|
||||||
container_name: mainflux-redis
|
container_name: mainflux-redis
|
||||||
restart: on-failure
|
restart: on-failure
|
||||||
|
networks:
|
||||||
|
- mainflux-base-net
|
||||||
|
|
||||||
mqtt-adapter:
|
mqtt-adapter:
|
||||||
image: mainflux/mqtt:latest
|
image: mainflux/mqtt:latest
|
||||||
@ -163,3 +189,5 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 1883:1883
|
- 1883:1883
|
||||||
- 8880:8880
|
- 8880:8880
|
||||||
|
networks:
|
||||||
|
- mainflux-base-net
|
||||||
|
10
readers/README.md
Normal file
10
readers/README.md
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# Readers
|
||||||
|
|
||||||
|
Readers provide an implementation of various `message readers`.
|
||||||
|
Message readers are services that consume normalized (in `SenML` format)
|
||||||
|
Mainflux messages from data storage and opens HTTP API for message consumption.
|
||||||
|
|
||||||
|
For an in-depth explanation of the usage of `reader`, as well as thorough
|
||||||
|
understanding of Mainflux, please check out the [official documentation][doc].
|
||||||
|
|
||||||
|
[doc]: http://mainflux.readthedocs.io
|
22
readers/api/endpoint.go
Normal file
22
readers/api/endpoint.go
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/go-kit/kit/endpoint"
|
||||||
|
"github.com/mainflux/mainflux/readers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func listMessagesEndpoint(svc readers.MessageRepository) endpoint.Endpoint {
|
||||||
|
return func(_ context.Context, request interface{}) (interface{}, error) {
|
||||||
|
req := request.(listMessagesReq)
|
||||||
|
|
||||||
|
if err := req.validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := svc.ReadAll(req.chanID, req.offset, req.limit)
|
||||||
|
|
||||||
|
return listMessagesRes{Messages: messages}, nil
|
||||||
|
}
|
||||||
|
}
|
155
readers/api/endpoint_test.go
Normal file
155
readers/api/endpoint_test.go
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mainflux/mainflux"
|
||||||
|
"github.com/mainflux/mainflux/readers"
|
||||||
|
"github.com/mainflux/mainflux/readers/api"
|
||||||
|
"github.com/mainflux/mainflux/readers/mocks"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
svcName = "test-service"
|
||||||
|
token = "1"
|
||||||
|
invalid = "invalid"
|
||||||
|
numOfMessages = 42
|
||||||
|
chanID uint64 = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
func newService() readers.MessageRepository {
|
||||||
|
messages := []mainflux.Message{}
|
||||||
|
for i := 0; i < numOfMessages; i++ {
|
||||||
|
messages = append(messages, mainflux.Message{
|
||||||
|
Channel: chanID,
|
||||||
|
Publisher: 1,
|
||||||
|
Protocol: "mqtt",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return mocks.NewMessageRepository(map[uint64][]mainflux.Message{
|
||||||
|
chanID: messages,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newServer(repo readers.MessageRepository, tc mainflux.ThingsServiceClient) *httptest.Server {
|
||||||
|
mux := api.MakeHandler(repo, tc, svcName)
|
||||||
|
return httptest.NewServer(mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
type testRequest struct {
|
||||||
|
client *http.Client
|
||||||
|
method string
|
||||||
|
url string
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr testRequest) make() (*http.Response, error) {
|
||||||
|
req, err := http.NewRequest(tr.method, tr.url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tr.token != "" {
|
||||||
|
req.Header.Set("Authorization", tr.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tr.client.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadAll(t *testing.T) {
|
||||||
|
svc := newService()
|
||||||
|
tc := mocks.NewThingsService()
|
||||||
|
ts := newServer(svc, tc)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
id := strconv.FormatUint(chanID, 10)
|
||||||
|
|
||||||
|
cases := map[string]struct {
|
||||||
|
url string
|
||||||
|
token string
|
||||||
|
status int
|
||||||
|
}{
|
||||||
|
"read page with valid offset and limit": {
|
||||||
|
url: fmt.Sprintf("%s/channels/%s/messages?offset=0&limit=10", ts.URL, id),
|
||||||
|
token: token,
|
||||||
|
status: http.StatusOK,
|
||||||
|
},
|
||||||
|
"read page with negative offset": {
|
||||||
|
url: fmt.Sprintf("%s/channels/%s/messages?offset=-1&limit=10", ts.URL, id),
|
||||||
|
token: token,
|
||||||
|
status: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
"read page with negative limit": {
|
||||||
|
url: fmt.Sprintf("%s/channels/%s/messages?offset=0&limit=-10", ts.URL, id),
|
||||||
|
token: token,
|
||||||
|
status: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
"read page with zero limit": {
|
||||||
|
url: fmt.Sprintf("%s/channels/%s/messages?offset=0&limit=0", ts.URL, id),
|
||||||
|
token: token,
|
||||||
|
status: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
"read page with non-integer offset": {
|
||||||
|
url: fmt.Sprintf("%s/channels/%s/messages?offset=abc&limit=10", ts.URL, id),
|
||||||
|
token: token,
|
||||||
|
status: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
"read page with non-integer limit": {
|
||||||
|
url: fmt.Sprintf("%s/channels/%s/messages?offset=0&limit=abc", ts.URL, id),
|
||||||
|
token: token,
|
||||||
|
status: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
"read page with invalid channel id": {
|
||||||
|
url: fmt.Sprintf("%s/channels/abc/messages?offset=0&limit=10", ts.URL),
|
||||||
|
token: token,
|
||||||
|
status: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
"read page with invalid token": {
|
||||||
|
url: fmt.Sprintf("%s/channels/%s/messages?offset=0&limit=10", ts.URL, id),
|
||||||
|
token: invalid,
|
||||||
|
status: http.StatusForbidden,
|
||||||
|
},
|
||||||
|
"read page with multiple offset": {
|
||||||
|
url: fmt.Sprintf("%s/channels/%s/messages?offset=0&offset=1&limit=10", ts.URL, id),
|
||||||
|
token: token,
|
||||||
|
status: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
"read page with multiple limit": {
|
||||||
|
url: fmt.Sprintf("%s/channels/%s/messages?offset=0&limit=20&limit=10", ts.URL, id),
|
||||||
|
token: token,
|
||||||
|
status: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
"read page with empty token": {
|
||||||
|
url: fmt.Sprintf("%s/channels/%s/messages?offset=0&limit=10", ts.URL, id),
|
||||||
|
token: "",
|
||||||
|
status: http.StatusForbidden,
|
||||||
|
},
|
||||||
|
"read page with default offset": {
|
||||||
|
url: fmt.Sprintf("%s/channels/%s/messages?limit=10", ts.URL, id),
|
||||||
|
token: token,
|
||||||
|
status: http.StatusOK,
|
||||||
|
},
|
||||||
|
"read page with default limit": {
|
||||||
|
url: fmt.Sprintf("%s/channels/%s/messages?offset=0", ts.URL, id),
|
||||||
|
token: token,
|
||||||
|
status: http.StatusOK,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for desc, tc := range cases {
|
||||||
|
req := testRequest{
|
||||||
|
client: ts.Client(),
|
||||||
|
method: http.MethodGet,
|
||||||
|
url: tc.url,
|
||||||
|
token: tc.token,
|
||||||
|
}
|
||||||
|
res, err := req.make()
|
||||||
|
assert.Nil(t, err, fmt.Sprintf("%s: unexpected error %s", desc, err))
|
||||||
|
assert.Equal(t, tc.status, res.StatusCode, fmt.Sprintf("%s: expected %d got %d", desc, tc.status, res.StatusCode))
|
||||||
|
}
|
||||||
|
}
|
36
readers/api/logging.go
Normal file
36
readers/api/logging.go
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
// +build !test
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mainflux/mainflux"
|
||||||
|
log "github.com/mainflux/mainflux/logger"
|
||||||
|
"github.com/mainflux/mainflux/readers"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ readers.MessageRepository = (*loggingMiddleware)(nil)
|
||||||
|
|
||||||
|
type loggingMiddleware struct {
|
||||||
|
logger log.Logger
|
||||||
|
svc readers.MessageRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoggingMiddleware adds logging facilities to the core service.
|
||||||
|
func LoggingMiddleware(svc readers.MessageRepository, logger log.Logger) readers.MessageRepository {
|
||||||
|
return &loggingMiddleware{
|
||||||
|
logger: logger,
|
||||||
|
svc: svc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lm *loggingMiddleware) ReadAll(chanID, offset, limit uint64) []mainflux.Message {
|
||||||
|
defer func(begin time.Time) {
|
||||||
|
lm.logger.Info(fmt.Sprintf(`Method read_all for offset %d and limit %d took
|
||||||
|
%s to complete without errors.`, offset, limit, time.Since(begin)))
|
||||||
|
}(time.Now())
|
||||||
|
|
||||||
|
return lm.svc.ReadAll(chanID, offset, limit)
|
||||||
|
}
|
38
readers/api/metrics.go
Normal file
38
readers/api/metrics.go
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
// +build !test
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-kit/kit/metrics"
|
||||||
|
"github.com/mainflux/mainflux"
|
||||||
|
"github.com/mainflux/mainflux/readers"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ readers.MessageRepository = (*metricsMiddleware)(nil)
|
||||||
|
|
||||||
|
type metricsMiddleware struct {
|
||||||
|
counter metrics.Counter
|
||||||
|
latency metrics.Histogram
|
||||||
|
svc readers.MessageRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetricsMiddleware instruments core service by tracking request count and
|
||||||
|
// latency.
|
||||||
|
func MetricsMiddleware(svc readers.MessageRepository, counter metrics.Counter, latency metrics.Histogram) readers.MessageRepository {
|
||||||
|
return &metricsMiddleware{
|
||||||
|
counter: counter,
|
||||||
|
latency: latency,
|
||||||
|
svc: svc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *metricsMiddleware) ReadAll(chanID, offset, limit uint64) []mainflux.Message {
|
||||||
|
defer func(begin time.Time) {
|
||||||
|
mm.counter.With("method", "read_all").Add(1)
|
||||||
|
mm.latency.With("method", "read_all").Observe(time.Since(begin).Seconds())
|
||||||
|
}(time.Now())
|
||||||
|
|
||||||
|
return mm.svc.ReadAll(chanID, offset, limit)
|
||||||
|
}
|
19
readers/api/requests.go
Normal file
19
readers/api/requests.go
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
type apiReq interface {
|
||||||
|
validate() error
|
||||||
|
}
|
||||||
|
|
||||||
|
type listMessagesReq struct {
|
||||||
|
chanID uint64
|
||||||
|
offset uint64
|
||||||
|
limit uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (req listMessagesReq) validate() error {
|
||||||
|
if req.limit < 1 {
|
||||||
|
return errInvalidRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
25
readers/api/responses.go
Normal file
25
readers/api/responses.go
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/mainflux/mainflux"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ mainflux.Response = (*listMessagesRes)(nil)
|
||||||
|
|
||||||
|
type listMessagesRes struct {
|
||||||
|
Messages []mainflux.Message `json:"messages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (res listMessagesRes) Headers() map[string]string {
|
||||||
|
return map[string]string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (res listMessagesRes) Code() int {
|
||||||
|
return http.StatusOK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (res listMessagesRes) Empty() bool {
|
||||||
|
return false
|
||||||
|
}
|
150
readers/api/transport.go
Normal file
150
readers/api/transport.go
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
kithttp "github.com/go-kit/kit/transport/http"
|
||||||
|
"github.com/go-zoo/bone"
|
||||||
|
"github.com/mainflux/mainflux"
|
||||||
|
"github.com/mainflux/mainflux/readers"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
contentType = "application/json"
|
||||||
|
defLimit = 10
|
||||||
|
defOffset = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errInvalidRequest = errors.New("received invalid request")
|
||||||
|
errUnauthorizedAccess = errors.New("missing or invalid credentials provided")
|
||||||
|
auth mainflux.ThingsServiceClient
|
||||||
|
)
|
||||||
|
|
||||||
|
// MakeHandler returns a HTTP handler for API endpoints.
|
||||||
|
func MakeHandler(svc readers.MessageRepository, tc mainflux.ThingsServiceClient, svcName string) http.Handler {
|
||||||
|
auth = tc
|
||||||
|
|
||||||
|
opts := []kithttp.ServerOption{
|
||||||
|
kithttp.ServerErrorEncoder(encodeError),
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := bone.New()
|
||||||
|
mux.Get("/channels/:chanID/messages", kithttp.NewServer(
|
||||||
|
listMessagesEndpoint(svc),
|
||||||
|
decodeList,
|
||||||
|
encodeResponse,
|
||||||
|
opts...,
|
||||||
|
))
|
||||||
|
|
||||||
|
mux.GetFunc("/version", mainflux.Version(svcName))
|
||||||
|
mux.Handle("/metrics", promhttp.Handler())
|
||||||
|
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeList(_ context.Context, r *http.Request) (interface{}, error) {
|
||||||
|
chanID, err := strconv.ParseUint(bone.GetValue(r, "chanID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errInvalidRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := authorize(r, chanID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
offset, err := getQuery(r, "offset", defOffset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, err := getQuery(r, "limit", defLimit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req := listMessagesReq{
|
||||||
|
chanID: chanID,
|
||||||
|
offset: offset,
|
||||||
|
limit: limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeResponse(_ context.Context, w http.ResponseWriter, response interface{}) error {
|
||||||
|
w.Header().Set("Content-Type", contentType)
|
||||||
|
|
||||||
|
if ar, ok := response.(mainflux.Response); ok {
|
||||||
|
for k, v := range ar.Headers() {
|
||||||
|
w.Header().Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(ar.Code())
|
||||||
|
|
||||||
|
if ar.Empty() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeError(_ context.Context, err error, w http.ResponseWriter) {
|
||||||
|
switch err {
|
||||||
|
case nil:
|
||||||
|
case errInvalidRequest:
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
case errUnauthorizedAccess:
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorize(r *http.Request, chanID uint64) error {
|
||||||
|
token := r.Header.Get("Authorization")
|
||||||
|
if token == "" {
|
||||||
|
return errUnauthorizedAccess
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err := auth.CanAccess(ctx, &mainflux.AccessReq{Token: token, ChanID: chanID})
|
||||||
|
if err != nil {
|
||||||
|
e, ok := status.FromError(err)
|
||||||
|
if ok && e.Code() == codes.PermissionDenied {
|
||||||
|
return errUnauthorizedAccess
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getQuery(req *http.Request, name string, fallback uint64) (uint64, error) {
|
||||||
|
vals := bone.GetQuery(req, name)
|
||||||
|
if len(vals) == 0 {
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(vals) > 1 {
|
||||||
|
return 0, errInvalidRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
val, err := strconv.ParseUint(vals[0], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errInvalidRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
return uint64(val), nil
|
||||||
|
}
|
72
readers/cassandra/README.md
Normal file
72
readers/cassandra/README.md
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
# Cassandra reader
|
||||||
|
|
||||||
|
Cassandra reader provides message repository implementation for Cassandra.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The service is configured using the environment variables presented in the
|
||||||
|
following table. Note that any unset variables will be replaced with their
|
||||||
|
default values.
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|---------------------------------|---------------------------------------------|----------------|
|
||||||
|
| MF_CASSANDRA_READER_PORT | Service HTTP port | 8180 |
|
||||||
|
| MF_CASSANDRA_READER_DB_CLUSTER | Cassandra cluster comma separated addresses | 127.0.0.1 |
|
||||||
|
| MF_CASSANDRA_READER_DB_KEYSPACE | Cassandra keyspace name | mainflux |
|
||||||
|
| MF_THINGS_URL | Things service URL | localhost:8181 |
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: "2"
|
||||||
|
cassandra-reader:
|
||||||
|
image: mainflux/cassandra-reader:[version]
|
||||||
|
container_name: [instance name]
|
||||||
|
expose:
|
||||||
|
- [Service HTTP port]
|
||||||
|
restart: on-failure
|
||||||
|
environment:
|
||||||
|
MF_THINGS_URL: [Things service URL]
|
||||||
|
MF_CASSANDRA_READER_PORT: [Service HTTP port]
|
||||||
|
MF_CASSANDRA_READER_DB_CLUSTER: [Cassandra cluster comma separated addresses]
|
||||||
|
MF_CASSANDRA_READER_DB_KEYSPACE: [Cassandra keyspace name]
|
||||||
|
ports:
|
||||||
|
- [host machine port]:[configured HTTP port]
|
||||||
|
```
|
||||||
|
|
||||||
|
To start the service, execute the following shell script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# download the latest version of the service
|
||||||
|
go get github.com/mainflux/mainflux
|
||||||
|
|
||||||
|
|
||||||
|
cd $GOPATH/src/github.com/mainflux/mainflux
|
||||||
|
|
||||||
|
# compile the cassandra
|
||||||
|
make cassandra-reader
|
||||||
|
|
||||||
|
# copy binary to bin
|
||||||
|
make install
|
||||||
|
|
||||||
|
# Set the environment variables and run the service
|
||||||
|
MF_THINGS_URL=[Things service URL] MF_CASSANDRA_READER_PORT=[Service HTTP port] MF_CASSANDRA_READER_DB_CLUSTER=[Cassandra cluster comma separated addresses] MF_CASSANDRA_READER_DB_KEYSPACE=[Cassandra keyspace name] $GOBIN/mainflux-cassandra-reader
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using docker-compose
|
||||||
|
|
||||||
|
This service can be deployed using docker containers. Docker compose file is
|
||||||
|
available in `<project_root>/docker/addons/cassandra-reader/docker-compose.yml`.
|
||||||
|
In order to run all Mainflux core services, as well as mentioned optional ones,
|
||||||
|
execute following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./docker/addons/cassandra-reader/init.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Service exposes HTTP API for fetching messages.
|
||||||
|
|
||||||
|
[doc]: ../swagger.yml
|
2
readers/cassandra/doc.go
Normal file
2
readers/cassandra/doc.go
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
// Package cassandra contains Cassandra specific reader implementation.
|
||||||
|
package cassandra
|
12
readers/cassandra/init.go
Normal file
12
readers/cassandra/init.go
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
package cassandra
|
||||||
|
|
||||||
|
import "github.com/gocql/gocql"
|
||||||
|
|
||||||
|
// Connect establishes connection to the Cassandra cluster.
|
||||||
|
func Connect(hosts []string, keyspace string) (*gocql.Session, error) {
|
||||||
|
cluster := gocql.NewCluster(hosts...)
|
||||||
|
cluster.Keyspace = keyspace
|
||||||
|
cluster.Consistency = gocql.Quorum
|
||||||
|
|
||||||
|
return cluster.CreateSession()
|
||||||
|
}
|
50
readers/cassandra/messages.go
Normal file
50
readers/cassandra/messages.go
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
package cassandra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gocql/gocql"
|
||||||
|
"github.com/mainflux/mainflux"
|
||||||
|
"github.com/mainflux/mainflux/readers"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ readers.MessageRepository = (*cassandraRepository)(nil)
|
||||||
|
|
||||||
|
type cassandraRepository struct {
|
||||||
|
session *gocql.Session
|
||||||
|
}
|
||||||
|
|
||||||
|
// New instantiates Cassandra message repository.
|
||||||
|
func New(session *gocql.Session) readers.MessageRepository {
|
||||||
|
return cassandraRepository{session: session}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cr cassandraRepository) ReadAll(chanID, offset, limit uint64) []mainflux.Message {
|
||||||
|
cql := `SELECT channel, publisher, protocol, name, unit,
|
||||||
|
value, string_value, bool_value, data_value, value_sum, time,
|
||||||
|
update_time, link FROM messages WHERE channel = ? LIMIT ?
|
||||||
|
ALLOW FILTERING`
|
||||||
|
|
||||||
|
iter := cr.session.Query(cql, chanID, offset+limit).Iter()
|
||||||
|
scanner := iter.Scanner()
|
||||||
|
|
||||||
|
// skip first OFFSET rows
|
||||||
|
for i := uint64(0); i < offset; i++ {
|
||||||
|
if !scanner.Next() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
page := []mainflux.Message{}
|
||||||
|
for scanner.Next() {
|
||||||
|
var msg mainflux.Message
|
||||||
|
scanner.Scan(&msg.Channel, &msg.Publisher, &msg.Protocol,
|
||||||
|
&msg.Name, &msg.Unit, &msg.Value, &msg.StringValue, &msg.BoolValue,
|
||||||
|
&msg.DataValue, &msg.ValueSum, &msg.Time, &msg.UpdateTime, &msg.Link)
|
||||||
|
page = append(page, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := iter.Close(); err != nil {
|
||||||
|
return []mainflux.Message{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return page
|
||||||
|
}
|
74
readers/cassandra/messages_test.go
Normal file
74
readers/cassandra/messages_test.go
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
package cassandra_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mainflux/mainflux"
|
||||||
|
readers "github.com/mainflux/mainflux/readers/cassandra"
|
||||||
|
writers "github.com/mainflux/mainflux/writers/cassandra"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyspace = "mainflux"
|
||||||
|
chanID = 1
|
||||||
|
numOfMessages = 42
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
addr = "localhost"
|
||||||
|
msg = mainflux.Message{
|
||||||
|
Channel: chanID,
|
||||||
|
Publisher: 1,
|
||||||
|
Protocol: "mqtt",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReadAll(t *testing.T) {
|
||||||
|
session, err := readers.Connect([]string{addr}, keyspace)
|
||||||
|
require.Nil(t, err, fmt.Sprintf("failed to connect to Cassandra: %s", err))
|
||||||
|
defer session.Close()
|
||||||
|
writer := writers.New(session)
|
||||||
|
|
||||||
|
messages := []mainflux.Message{}
|
||||||
|
for i := 0; i < numOfMessages; i++ {
|
||||||
|
err := writer.Save(msg)
|
||||||
|
require.Nil(t, err, fmt.Sprintf("failed to store message to Cassandra: %s", err))
|
||||||
|
messages = append(messages, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := readers.New(session)
|
||||||
|
|
||||||
|
cases := map[string]struct {
|
||||||
|
chanID uint64
|
||||||
|
offset uint64
|
||||||
|
limit uint64
|
||||||
|
messages []mainflux.Message
|
||||||
|
}{
|
||||||
|
"read message page for existing channel": {
|
||||||
|
chanID: chanID,
|
||||||
|
offset: 0,
|
||||||
|
limit: 10,
|
||||||
|
messages: messages[0:10],
|
||||||
|
},
|
||||||
|
"read message page for non-existent channel": {
|
||||||
|
chanID: 2,
|
||||||
|
offset: 0,
|
||||||
|
limit: 10,
|
||||||
|
messages: []mainflux.Message{},
|
||||||
|
},
|
||||||
|
"read message last page": {
|
||||||
|
chanID: chanID,
|
||||||
|
offset: 40,
|
||||||
|
limit: 10,
|
||||||
|
messages: messages[40:42],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for desc, tc := range cases {
|
||||||
|
result := reader.ReadAll(tc.chanID, tc.offset, tc.limit)
|
||||||
|
assert.ElementsMatch(t, tc.messages, result, fmt.Sprintf("%s: expected %v got %v", desc, tc.messages, result))
|
||||||
|
}
|
||||||
|
}
|
72
readers/cassandra/setup_test.go
Normal file
72
readers/cassandra/setup_test.go
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
package cassandra_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gocql/gocql"
|
||||||
|
log "github.com/mainflux/mainflux/logger"
|
||||||
|
"github.com/mainflux/mainflux/writers/cassandra"
|
||||||
|
dockertest "gopkg.in/ory-am/dockertest.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var logger = log.New(os.Stdout)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
pool, err := dockertest.NewPool("")
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(fmt.Sprintf("Could not connect to docker: %s", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
container, err := pool.Run("cassandra", "3.11.2", []string{})
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(fmt.Sprintf("Could not start container: %s", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
port := container.GetPort("9042/tcp")
|
||||||
|
addr = fmt.Sprintf("%s:%s", addr, port)
|
||||||
|
|
||||||
|
err = pool.Retry(func() error {
|
||||||
|
if err := createKeyspace([]string{addr}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := cassandra.Connect([]string{addr}, keyspace)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(fmt.Sprintf("Could not connect to docker: %s", err))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
code := m.Run()
|
||||||
|
|
||||||
|
if err := pool.Purge(container); err != nil {
|
||||||
|
logger.Error(fmt.Sprintf("Could not purge container: %s", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Exit(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createKeyspace(hosts []string) error {
|
||||||
|
cluster := gocql.NewCluster(hosts...)
|
||||||
|
cluster.Consistency = gocql.Quorum
|
||||||
|
|
||||||
|
session, err := cluster.CreateSession()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
keyspaceCQL := fmt.Sprintf(`CREATE KEYSPACE IF NOT EXISTS %s WITH replication =
|
||||||
|
{'class':'SimpleStrategy','replication_factor':'1'}`, keyspace)
|
||||||
|
|
||||||
|
return session.Query(keyspaceCQL).Exec()
|
||||||
|
}
|
17
readers/messages.go
Normal file
17
readers/messages.go
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
package readers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/mainflux/mainflux"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNotFound indicates that requested entity doesn't exist.
|
||||||
|
var ErrNotFound = errors.New("entity not found")
|
||||||
|
|
||||||
|
// MessageRepository specifies message reader API.
|
||||||
|
type MessageRepository interface {
|
||||||
|
// ReadAll skips given number of messages for given channel and returns next
|
||||||
|
// limited number of messages.
|
||||||
|
ReadAll(uint64, uint64, uint64) []mainflux.Message
|
||||||
|
}
|
45
readers/mocks/messages.go
Normal file
45
readers/mocks/messages.go
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
package mocks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/mainflux/mainflux"
|
||||||
|
"github.com/mainflux/mainflux/readers"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ readers.MessageRepository = (*messageRepositoryMock)(nil)
|
||||||
|
|
||||||
|
type messageRepositoryMock struct {
|
||||||
|
mutex sync.Mutex
|
||||||
|
messages map[uint64][]mainflux.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMessageRepository returns mock implementation of message repository.
|
||||||
|
func NewMessageRepository(messages map[uint64][]mainflux.Message) readers.MessageRepository {
|
||||||
|
return &messageRepositoryMock{
|
||||||
|
mutex: sync.Mutex{},
|
||||||
|
messages: messages,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repo *messageRepositoryMock) ReadAll(chanID, offset, limit uint64) []mainflux.Message {
|
||||||
|
repo.mutex.Lock()
|
||||||
|
defer repo.mutex.Unlock()
|
||||||
|
|
||||||
|
end := offset + limit
|
||||||
|
|
||||||
|
numOfMessages := uint64(len(repo.messages[chanID]))
|
||||||
|
if offset < 0 || offset >= numOfMessages {
|
||||||
|
return []mainflux.Message{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if limit < 1 {
|
||||||
|
return []mainflux.Message{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if offset+limit > numOfMessages {
|
||||||
|
end = numOfMessages
|
||||||
|
}
|
||||||
|
|
||||||
|
return repo.messages[chanID][offset:end]
|
||||||
|
}
|
40
readers/mocks/things.go
Normal file
40
readers/mocks/things.go
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
package mocks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/mainflux/mainflux"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errUnathorized = status.Error(codes.PermissionDenied, "missing or invalid credentials provided")
|
||||||
|
|
||||||
|
var _ mainflux.ThingsServiceClient = (*thingsServiceMock)(nil)
|
||||||
|
|
||||||
|
type thingsServiceMock struct{}
|
||||||
|
|
||||||
|
// NewThingsService returns mock implementation of things service
|
||||||
|
func NewThingsService() mainflux.ThingsServiceClient {
|
||||||
|
return thingsServiceMock{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc thingsServiceMock) CanAccess(ctx context.Context, in *mainflux.AccessReq, opts ...grpc.CallOption) (*mainflux.ThingID, error) {
|
||||||
|
token := in.GetToken()
|
||||||
|
if token == "invalid" {
|
||||||
|
return nil, errUnathorized
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := strconv.ParseUint(token, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errUnathorized
|
||||||
|
}
|
||||||
|
|
||||||
|
return &mainflux.ThingID{Value: id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc thingsServiceMock) Identify(_ context.Context, _ *mainflux.Token, _ ...grpc.CallOption) (*mainflux.ThingID, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
122
readers/swagger.yml
Normal file
122
readers/swagger.yml
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
swagger: "2.0"
|
||||||
|
info:
|
||||||
|
title: Mainflux reader service
|
||||||
|
description: HTTP API for reading messages.
|
||||||
|
version: "1.0.0"
|
||||||
|
consumes:
|
||||||
|
- "application/json"
|
||||||
|
produces:
|
||||||
|
- "application/json"
|
||||||
|
paths:
|
||||||
|
/channels/{chanId}/messages:
|
||||||
|
get:
|
||||||
|
summary: Retrieves messages sent to single channel
|
||||||
|
description: |
|
||||||
|
Retrieves a list of messages sent to specific channel. Due to
|
||||||
|
performance concerns, data is retrieved in subsets. The API readers must
|
||||||
|
ensure that the entire dataset is consumed either by making subsequent
|
||||||
|
requests, or by increasing the subset size of the initial request.
|
||||||
|
tags:
|
||||||
|
- messages
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/parameters/Authorization"
|
||||||
|
- $ref: "#/parameters/Limit"
|
||||||
|
- $ref: "#/parameters/Offset"
|
||||||
|
- $ref: "#/parameters/ChanId"
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Data retrieved.
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/MessageList"
|
||||||
|
400:
|
||||||
|
description: Failed due to malformed query parameters.
|
||||||
|
403:
|
||||||
|
description: Missing or invalid access token provided.
|
||||||
|
500:
|
||||||
|
$ref: "#/responses/ServiceError"
|
||||||
|
|
||||||
|
responses:
|
||||||
|
ServiceError:
|
||||||
|
description: Unexpected server-side error occured.
|
||||||
|
|
||||||
|
definitions:
|
||||||
|
MessageList:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
channels:
|
||||||
|
type: array
|
||||||
|
minItems: 0
|
||||||
|
uniqueItems: true
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
Channel:
|
||||||
|
type: integer
|
||||||
|
description: Unique channel id.
|
||||||
|
Publisher:
|
||||||
|
type: integer
|
||||||
|
description: Unique publisher id.
|
||||||
|
Protocol:
|
||||||
|
type: string
|
||||||
|
description: Protocol name.
|
||||||
|
Name:
|
||||||
|
type: string
|
||||||
|
description: Measured parameter name.
|
||||||
|
Unit:
|
||||||
|
type: string
|
||||||
|
description: Value unit.
|
||||||
|
Value:
|
||||||
|
type: number
|
||||||
|
description: Measured value in number.
|
||||||
|
StringValue:
|
||||||
|
type: string
|
||||||
|
description: Measured value in string format.
|
||||||
|
BoolValue:
|
||||||
|
type: boolean
|
||||||
|
description: Measured value in boolean format.
|
||||||
|
DataValue:
|
||||||
|
type: string
|
||||||
|
description: Measured value in binary format.
|
||||||
|
ValueSum:
|
||||||
|
type: number
|
||||||
|
description: Sum value.
|
||||||
|
Time:
|
||||||
|
type: number
|
||||||
|
description: Time of measurement.
|
||||||
|
UpdateTime:
|
||||||
|
type: number
|
||||||
|
description: Time of updating measurement.
|
||||||
|
Link:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
Authorization:
|
||||||
|
name: Authorization
|
||||||
|
description: Thing access token.
|
||||||
|
in: header
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
ChanId:
|
||||||
|
name: chanId
|
||||||
|
description: Unique channel identifier.
|
||||||
|
in: path
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
required: true
|
||||||
|
Limit:
|
||||||
|
name: limit
|
||||||
|
description: Size of the subset to retrieve.
|
||||||
|
in: query
|
||||||
|
type: integer
|
||||||
|
default: 10
|
||||||
|
maximum: 100
|
||||||
|
minimum: 1
|
||||||
|
required: false
|
||||||
|
Offset:
|
||||||
|
name: offset
|
||||||
|
description: Number of items to skip during retrieval.
|
||||||
|
in: query
|
||||||
|
type: integer
|
||||||
|
default: 0
|
||||||
|
minimum: 0
|
||||||
|
required: false
|
@ -20,7 +20,7 @@ default values.
|
|||||||
```yaml
|
```yaml
|
||||||
version: "2"
|
version: "2"
|
||||||
cassandra-writer:
|
cassandra-writer:
|
||||||
image: mainflux/cassandra:[version]
|
image: mainflux/cassandra-writer:[version]
|
||||||
container_name: [instance name]
|
container_name: [instance name]
|
||||||
expose:
|
expose:
|
||||||
- [Service HTTP port]
|
- [Service HTTP port]
|
||||||
@ -43,26 +43,26 @@ go get github.com/mainflux/mainflux
|
|||||||
|
|
||||||
cd $GOPATH/src/github.com/mainflux/mainflux
|
cd $GOPATH/src/github.com/mainflux/mainflux
|
||||||
|
|
||||||
# compile the cassandra
|
# compile the cassandra writer
|
||||||
make cassandra
|
make cassandra-writer
|
||||||
|
|
||||||
# copy binary to bin
|
# copy binary to bin
|
||||||
make install
|
make install
|
||||||
|
|
||||||
# Set the environment variables and run the service
|
# Set the environment variables and run the service
|
||||||
MF_NATS_URL=[NATS instance URL] MF_CASSANDRA_WRITER_PORT=[Service HTTP port] MF_CASSANDRA_WRITER_DB_CLUSTER=[Cassandra cluster comma separated addresses] MF_CASSANDRA_WRITER_DB_KEYSPACE=[Cassandra keyspace name] $GOBIN/mainflux-cassandra
|
MF_NATS_URL=[NATS instance URL] MF_CASSANDRA_WRITER_PORT=[Service HTTP port] MF_CASSANDRA_WRITER_DB_CLUSTER=[Cassandra cluster comma separated addresses] MF_CASSANDRA_WRITER_DB_KEYSPACE=[Cassandra keyspace name] $GOBIN/mainflux-cassandra-writer
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using docker-compose
|
### Using docker-compose
|
||||||
|
|
||||||
This service can be deployed using docker containers. Docker compose file is
|
This service can be deployed using docker containers. Docker compose file is
|
||||||
available in `<project_root>/docker/addons/cassandra/docker-compose.yml`. In
|
available in `<project_root>/docker/addons/cassandra-writer/docker-compose.yml`.
|
||||||
order to run all Mainflux core services, as well as mentioned optional ones,
|
In order to run all Mainflux core services, as well as mentioned optional ones,
|
||||||
execute following command:
|
execute following command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./docker/addons/cassandra/init.sh
|
./docker/addons/cassandra-writer/init.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
@ -16,7 +16,7 @@ const table = `CREATE TABLE IF NOT EXISTS messages (
|
|||||||
value_sum double,
|
value_sum double,
|
||||||
time double,
|
time double,
|
||||||
update_time double,
|
update_time double,
|
||||||
link text,
|
link text
|
||||||
)`
|
)`
|
||||||
|
|
||||||
// Connect establishes connection to the Cassandra cluster.
|
// Connect establishes connection to the Cassandra cluster.
|
||||||
@ -30,8 +30,7 @@ func Connect(hosts []string, keyspace string) (*gocql.Session, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = session.Query(table).Exec()
|
if err := session.Query(table).Exec(); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -13,8 +13,12 @@ import (
|
|||||||
const keyspace = "mainflux"
|
const keyspace = "mainflux"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
msg = mainflux.Message{}
|
|
||||||
addr = "localhost"
|
addr = "localhost"
|
||||||
|
msg = mainflux.Message{
|
||||||
|
Channel: 1,
|
||||||
|
Publisher: 1,
|
||||||
|
Protocol: "mqtt",
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSave(t *testing.T) {
|
func TestSave(t *testing.T) {
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
package writers
|
package writers
|
||||||
|
|
||||||
import (
|
import "github.com/mainflux/mainflux"
|
||||||
"github.com/mainflux/mainflux"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MessageRepository specifies message reading API.
|
// MessageRepository specifies message writing API.
|
||||||
type MessageRepository interface {
|
type MessageRepository interface {
|
||||||
|
|
||||||
// Save method is used to save published message. A non-nil
|
// Save method is used to save published message. A non-nil
|
||||||
|
Loading…
x
Reference in New Issue
Block a user