NOISSUE - Add name field for Bootstrap Config (#564)

* Add name field to Config

Enable search by name.

Signed-off-by: Dusan Borovcanin <dusan.borovcanin@mainflux.com>

* Create separate response for unknown Configs

Signed-off-by: Dusan Borovcanin <dusan.borovcanin@mainflux.com>

* Use meaningful names for filters

Signed-off-by: Dusan Borovcanin <dusan.borovcanin@mainflux.com>

* Add name search tests

Signed-off-by: Dusan Borovcanin <dusan.borovcanin@mainflux.com>

* Update API docs

Signed-off-by: Dusan Borovcanin <dusan.borovcanin@mainflux.com>

* Break mocks check into multiple lines

Signed-off-by: Dusan Borovcanin <dusan.borovcanin@mainflux.com>

* Create new instances in a consistent way

Reformat `return` statements.

Signed-off-by: Dusan Borovcanin <dusan.borovcanin@mainflux.com>
This commit is contained in:
Dušan Borovčanin 2019-02-06 10:28:54 +01:00 committed by Aleksandar Novaković
parent 8966a13760
commit 1df4dcd7b7
14 changed files with 198 additions and 78 deletions

View File

@ -30,6 +30,7 @@ func addEndpoint(svc bootstrap.Service) endpoint.Endpoint {
ExternalID: req.ExternalID,
ExternalKey: req.ExternalKey,
MFChannels: channels,
Name: req.Name,
Content: req.Content,
}
@ -75,6 +76,7 @@ func viewEndpoint(svc bootstrap.Service) endpoint.Endpoint {
Channels: channels,
ExternalID: config.ExternalID,
ExternalKey: config.ExternalKey,
Name: config.Name,
Content: config.Content,
State: config.State,
}
@ -99,6 +101,7 @@ func updateEndpoint(svc bootstrap.Service) endpoint.Endpoint {
config := bootstrap.Config{
MFThing: req.id,
MFChannels: channels,
Name: req.Name,
Content: req.Content,
State: req.State,
}
@ -128,34 +131,46 @@ func listEndpoint(svc bootstrap.Service) endpoint.Endpoint {
if err != nil {
return nil, err
}
res := listRes{
Configs: []viewRes{},
}
for _, cfg := range configs {
var channels []channelRes
for _, ch := range cfg.MFChannels {
channels = append(channels, channelRes{
ID: ch.ID,
Name: ch.Name,
Metadata: ch.Metadata,
switch {
case req.filter.Unknown:
res := listUnknownRes{}
for _, cfg := range configs {
res.Configs = append(res.Configs, unknownRes{
ExternalID: cfg.ExternalID,
ExternalKey: cfg.ExternalKey,
})
}
view := viewRes{
MFThing: cfg.MFThing,
MFKey: cfg.MFKey,
Channels: channels,
ExternalID: cfg.ExternalID,
ExternalKey: cfg.ExternalKey,
Content: cfg.Content,
State: cfg.State,
return res, nil
default:
res := listRes{
Configs: []viewRes{},
}
res.Configs = append(res.Configs, view)
}
return res, nil
for _, cfg := range configs {
var channels []channelRes
for _, ch := range cfg.MFChannels {
channels = append(channels, channelRes{
ID: ch.ID,
Name: ch.Name,
Metadata: ch.Metadata,
})
}
view := viewRes{
MFThing: cfg.MFThing,
MFKey: cfg.MFKey,
Channels: channels,
ExternalID: cfg.ExternalID,
ExternalKey: cfg.ExternalKey,
Name: cfg.Name,
Content: cfg.Content,
State: cfg.State,
}
res.Configs = append(res.Configs, view)
}
return res, nil
}
}
}

View File

@ -40,6 +40,7 @@ const (
metadata = `{"meta": "data"}`
addExternalID = "external-id"
addExternalKey = "external-key"
addName = "name"
addContent = "config"
)
@ -49,12 +50,14 @@ var (
ExternalID string `json:"external_id"`
ExternalKey string `json:"external_key"`
Channels []string `json:"channels"`
Name string `json:"name"`
Content string `json:"content"`
}{
ExternalID: addExternalID,
ExternalKey: addExternalKey,
Channels: addChannels,
Content: addContent,
ExternalID: "external-id",
ExternalKey: "external-key",
Channels: []string{"1"},
Name: "name",
Content: "config",
}
updateReq = struct {
@ -82,6 +85,7 @@ func newConfig(channels []bootstrap.Channel) bootstrap.Config {
ExternalID: addExternalID,
ExternalKey: addExternalKey,
MFChannels: channels,
Name: addName,
Content: addContent,
}
}
@ -282,6 +286,7 @@ func TestView(t *testing.T) {
Channels: channels,
ExternalID: saved.ExternalID,
ExternalKey: saved.ExternalKey,
Name: saved.Name,
Content: saved.Content,
})
@ -464,7 +469,8 @@ func TestList(t *testing.T) {
for i := 0; i < configNum; i++ {
c.ExternalID = strconv.Itoa(i)
c.MFKey = c.ExternalID
c.ExternalKey = fmt.Sprintf("%s%s", c.ExternalKey, strconv.Itoa(i))
c.Name = fmt.Sprintf("%s-%d", addName, i)
c.ExternalKey = fmt.Sprintf("%s%s", addExternalKey, strconv.Itoa(i))
saved, err := svc.Add(validToken, c)
require.Nil(t, err, fmt.Sprintf("Saving config expected to succeed: %s.\n", err))
var channels []channel
@ -477,6 +483,7 @@ func TestList(t *testing.T) {
Channels: channels,
ExternalID: saved.ExternalID,
ExternalKey: saved.ExternalKey,
Name: saved.Name,
Content: saved.Content,
State: saved.State,
}
@ -527,6 +534,13 @@ func TestList(t *testing.T) {
status: http.StatusOK,
res: list[0:1],
},
{
desc: "view list searching by name",
auth: validToken,
url: fmt.Sprintf("%s/configs?offset=%d&limit=%d&name=%s", bs.URL, 0, 100, "95"),
status: http.StatusOK,
res: list[95:96],
},
{
desc: "view last page",
auth: validToken,
@ -1001,5 +1015,6 @@ type config struct {
ExternalID string `json:"external_id"`
ExternalKey string `json:"external_key,omitempty"`
Content string `json:"content,omitempty"`
Name string `json:"name"`
State bootstrap.State `json:"state"`
}

View File

@ -18,6 +18,7 @@ type addReq struct {
ExternalID string `json:"external_id"`
ExternalKey string `json:"external_key"`
Channels []string `json:"channels"`
Name string `json:"name"`
Content string `json:"content"`
}
@ -46,6 +47,7 @@ type updateReq struct {
key string
id string
Channels []string `json:"channels"`
Name string `json:"name"`
Content string `json:"content"`
State bootstrap.State `json:"state"`
}

View File

@ -78,6 +78,7 @@ type viewRes struct {
ExternalID string `json:"external_id"`
ExternalKey string `json:"external_key,omitempty"`
Content string `json:"content,omitempty"`
Name string `json:"name,omitempty"`
State bootstrap.State `json:"state"`
}
@ -93,6 +94,27 @@ func (res viewRes) Empty() bool {
return false
}
type unknownRes struct {
ExternalID string `json:"external_id"`
ExternalKey string `json:"external_key,omitempty"`
}
type listUnknownRes struct {
Configs []unknownRes `json:"configs"`
}
func (res listUnknownRes) Code() int {
return http.StatusOK
}
func (res listUnknownRes) Headers() map[string]string {
return map[string]string{}
}
func (res listUnknownRes) Empty() bool {
return false
}
type listRes struct {
Configs []viewRes `json:"configs"`
}

View File

@ -15,6 +15,7 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
"github.com/mainflux/mainflux/bootstrap"
@ -34,7 +35,8 @@ const (
var (
errUnsupportedContentType = errors.New("unsupported content type")
errInvalidQueryParams = errors.New("invalid query params")
validParams = []string{"state", "external_id", "mainflux_id", "mainflux_key"}
fullMatch = []string{"state", "external_id", "mainflux_id", "mainflux_key"}
partialMatch = []string{"name"}
)
// MakeHandler returns a HTTP handler for API endpoints.
@ -138,7 +140,7 @@ func decodeUnknownRequest(_ context.Context, r *http.Request) (interface{}, erro
req := listReq{
key: r.Header.Get("Authorization"),
filter: bootstrap.Filter{"unknown": "true"},
filter: bootstrap.Filter{Unknown: true},
offset: offset,
limit: limit,
}
@ -257,6 +259,7 @@ func parseUint(s string) (uint64, error) {
if err != nil {
return 0, errInvalidQueryParams
}
return ret, nil
}
@ -285,12 +288,19 @@ func parsePagePrams(q url.Values) (uint64, uint64, error) {
}
func parseFilter(values url.Values) bootstrap.Filter {
ret := bootstrap.Filter{}
ret := bootstrap.Filter{
FullMatch: make(map[string]string),
PartialMatch: make(map[string]string),
}
for k := range values {
if contains(validParams, k) {
ret[k] = values.Get(k)
if contains(fullMatch, k) {
ret.FullMatch[k] = values.Get(k)
}
if contains(partialMatch, k) {
ret.PartialMatch[k] = strings.ToLower(values.Get(k))
}
}
return ret
}

View File

@ -22,6 +22,7 @@ const (
type Config struct {
MFThing string
Owner string
Name string
MFKey string
MFChannels []Channel
ExternalID string
@ -38,7 +39,11 @@ type Channel struct {
}
// Filter is used for the search filters.
type Filter map[string]string
type Filter struct {
Unknown bool
FullMatch map[string]string
PartialMatch map[string]string
}
// ConfigRepository specifies a Config persistence API.
type ConfigRepository interface {

View File

@ -10,6 +10,7 @@ package mocks
import (
"sort"
"strconv"
"strings"
"sync"
"github.com/mainflux/mainflux/bootstrap"
@ -73,15 +74,22 @@ func (crm *configRepositoryMock) RetrieveAll(key string, filter bootstrap.Filter
first := uint64(offset) + 1
last := first + uint64(limit)
var state bootstrap.State = -1
if s, ok := filter["state"]; ok {
var name string
if s, ok := filter.FullMatch["state"]; ok {
val, _ := strconv.Atoi(s)
state = bootstrap.State(val)
}
if s, ok := filter.PartialMatch["name"]; ok {
name = strings.ToLower(s)
}
for _, v := range crm.configs {
id, _ := strconv.ParseUint(v.MFThing, 10, 64)
if id >= first && id < last {
if (state == -1 || v.State == state) && v.Owner == key {
if (state == -1 || v.State == state) &&
(name == "" || strings.Index(strings.ToLower(v.Name), name) != -1) &&
v.Owner == key {
configs = append(configs, v)
}
}

View File

@ -33,7 +33,7 @@ type configRepository struct {
log logger.Logger
}
type writeCh struct {
type dbChannel struct {
ID string `json:"id"`
Name string `json:"name"`
Metadata interface{} `json:"metadata"`
@ -57,17 +57,18 @@ func nullString(s string) sql.NullString {
}
func (cr configRepository) Save(cfg bootstrap.Config) (string, error) {
q := `INSERT INTO configs (mainflux_thing, owner, mainflux_key, external_id, external_key, content, state, mainflux_channels)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`
q := `INSERT INTO configs (mainflux_thing, owner, name, mainflux_key, external_id, external_key, content, state, mainflux_channels)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`
channels := toDBChannels(cfg.MFChannels)
jsn, err := json.Marshal(channels)
if err != nil {
return "", bootstrap.ErrMalformedEntity
}
content := nullString(cfg.Content)
name := nullString(cfg.Name)
if _, err := cr.db.Exec(q, cfg.MFThing, cfg.Owner, cfg.MFKey, cfg.ExternalID, cfg.ExternalKey, cfg.Content, cfg.State, jsn); err != nil {
if _, err := cr.db.Exec(q, cfg.MFThing, cfg.Owner, name, cfg.MFKey, cfg.ExternalID, cfg.ExternalKey, content, cfg.State, jsn); err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Code.Name() == duplicateErr {
return "", bootstrap.ErrConflict
}
@ -78,12 +79,12 @@ func (cr configRepository) Save(cfg bootstrap.Config) (string, error) {
}
func (cr configRepository) RetrieveByID(key, id string) (bootstrap.Config, error) {
q := `SELECT mainflux_thing, mainflux_key, external_id, external_key, content, state, mainflux_channels FROM configs WHERE mainflux_thing = $1 AND owner = $2`
q := `SELECT mainflux_thing, mainflux_key, external_id, external_key, name, content, state, mainflux_channels FROM configs WHERE mainflux_thing = $1 AND owner = $2`
cfg := bootstrap.Config{MFThing: id, Owner: key, MFChannels: []bootstrap.Channel{}}
var content sql.NullString
var name, content sql.NullString
var chs []byte
if err := cr.db.QueryRow(q, id, key).
Scan(&cfg.MFThing, &cfg.MFKey, &cfg.ExternalID, &cfg.ExternalKey, &content, &cfg.State, &chs); err != nil {
Scan(&cfg.MFThing, &cfg.MFKey, &cfg.ExternalID, &cfg.ExternalKey, &name, &content, &cfg.State, &chs); err != nil {
empty := bootstrap.Config{}
if err == sql.ErrNoRows {
return empty, bootstrap.ErrNotFound
@ -96,6 +97,7 @@ func (cr configRepository) RetrieveByID(key, id string) (bootstrap.Config, error
}
cfg.Content = content.String
cfg.Name = name.String
return cfg, nil
}
@ -108,13 +110,13 @@ func (cr configRepository) RetrieveAll(key string, filter bootstrap.Filter, offs
}
defer rows.Close()
var content sql.NullString
var name, content sql.NullString
configs := []bootstrap.Config{}
for rows.Next() {
var chs []byte
c := bootstrap.Config{Owner: key}
if err := rows.Scan(&c.MFThing, &c.MFKey, &c.ExternalID, &c.ExternalKey, &content, &c.State, &chs); err != nil {
if err := rows.Scan(&c.MFThing, &c.MFKey, &c.ExternalID, &c.ExternalKey, &name, &content, &c.State, &chs); err != nil {
cr.log.Error(fmt.Sprintf("Failed to read retrieved config due to %s", err))
return []bootstrap.Config{}
}
@ -124,6 +126,7 @@ func (cr configRepository) RetrieveAll(key string, filter bootstrap.Filter, offs
return []bootstrap.Config{}
}
c.Name = name.String
c.Content = content.String
configs = append(configs, c)
}
@ -132,8 +135,10 @@ func (cr configRepository) RetrieveAll(key string, filter bootstrap.Filter, offs
}
func (cr configRepository) RetrieveByExternalID(externalKey, externalID string) (bootstrap.Config, error) {
q := `SELECT mainflux_thing, owner, mainflux_key, content, state, mainflux_channels FROM configs WHERE external_key = $1 AND external_id = $2`
var content sql.NullString
q := `SELECT mainflux_thing, owner, mainflux_key, name, content, state, mainflux_channels
FROM configs WHERE external_key = $1 AND external_id = $2`
var name, content sql.NullString
cfg := bootstrap.Config{
ExternalID: externalID,
ExternalKey: externalKey,
@ -141,7 +146,7 @@ func (cr configRepository) RetrieveByExternalID(externalKey, externalID string)
var chs []byte
if err := cr.db.QueryRow(q, externalKey, externalID).
Scan(&cfg.MFThing, &cfg.Owner, &cfg.MFKey, &content, &cfg.State, &chs); err != nil {
Scan(&cfg.MFThing, &cfg.Owner, &cfg.MFKey, &name, &content, &cfg.State, &chs); err != nil {
empty := bootstrap.Config{}
if err == sql.ErrNoRows {
return empty, bootstrap.ErrNotFound
@ -159,7 +164,7 @@ func (cr configRepository) RetrieveByExternalID(externalKey, externalID string)
}
func (cr configRepository) Update(cfg bootstrap.Config) error {
q := `UPDATE configs SET content = $1, state = $2, mainflux_channels = $3 WHERE mainflux_thing = $4 AND owner = $5`
q := `UPDATE configs SET name = $1, content = $2, state = $3, mainflux_channels = $4 WHERE mainflux_thing = $5 AND owner = $6`
channels := toDBChannels(cfg.MFChannels)
@ -169,7 +174,10 @@ func (cr configRepository) Update(cfg bootstrap.Config) error {
return bootstrap.ErrMalformedEntity
}
res, err := cr.db.Exec(q, cfg.Content, cfg.State, jsn, cfg.MFThing, cfg.Owner)
name := nullString(cfg.Name)
content := nullString(cfg.Content)
res, err := cr.db.Exec(q, name, content, cfg.State, jsn, cfg.MFThing, cfg.Owner)
if err != nil {
return err
}
@ -189,6 +197,7 @@ func (cr configRepository) Update(cfg bootstrap.Config) error {
func (cr configRepository) Remove(key, id string) error {
q := `DELETE FROM configs WHERE mainflux_thing = $1 AND owner = $2`
cr.db.Exec(q, id, key)
return nil
}
@ -251,31 +260,45 @@ func (cr configRepository) RetrieveUnknown(offset, limit uint64) []bootstrap.Con
func (cr configRepository) RemoveUnknown(key, id string) error {
q := `DELETE FROM unknown_configs WHERE external_id = $1 AND external_key = $2`
_, err := cr.db.Exec(q, id, key)
return err
}
func (cr configRepository) retrieveAll(key string, filter bootstrap.Filter, offset, limit uint64) (*sql.Rows, error) {
template := `SELECT mainflux_thing, mainflux_key, external_id, external_key, content, state, mainflux_channels FROM configs WHERE owner = $1 %s ORDER BY mainflux_thing LIMIT $2 OFFSET $3`
template := `SELECT mainflux_thing, mainflux_key, external_id, external_key, name, content, state, mainflux_channels
FROM configs WHERE owner = $1 %s ORDER BY mainflux_thing LIMIT $2 OFFSET $3`
params := []interface{}{key, limit, offset}
// One empty string so that strings Join works if only one filter is applied.
queries := []string{""}
// Since key = 1, limit = 2, offset = 3, the next one is 4.
counter := len(params) + 1
for k, v := range filter {
for k, v := range filter.FullMatch {
queries = append(queries, fmt.Sprintf("%s = $%d", k, counter))
params = append(params, v)
counter++
}
for k, v := range filter.PartialMatch {
queries = append(queries, fmt.Sprintf("LOWER(%s) LIKE '%%' || $%d || '%%'", k, counter))
params = append(params, v)
counter++
}
f := strings.Join(queries, " AND ")
return cr.db.Query(fmt.Sprintf(template, f), params...)
}
func toDBChannels(channels []bootstrap.Channel) []writeCh {
ret := []writeCh{}
func toDBChannels(channels []bootstrap.Channel) []dbChannel {
ret := []dbChannel{}
for _, ch := range channels {
c := writeCh{ID: ch.ID, Name: ch.Name, Metadata: ch.Metadata}
c := dbChannel{
ID: ch.ID,
Name: ch.Name,
Metadata: ch.Metadata,
}
ret = append(ret, c)
}
return ret
}

View File

@ -114,6 +114,7 @@ func TestRetrieveAll(t *testing.T) {
// Use UUID to prevent conflict errors.
id := uuid.NewV4().String()
c.ExternalID = id
c.Name = fmt.Sprintf("name %d", i)
c.MFThing = id
c.MFKey = id
if i%2 == 0 {
@ -156,9 +157,17 @@ func TestRetrieveAll(t *testing.T) {
owner: config.Owner,
offset: 0,
limit: uint64(numConfigs),
filter: bootstrap.Filter{"state": bootstrap.Active.String()},
filter: bootstrap.Filter{FullMatch: map[string]string{"state": bootstrap.Active.String()}},
size: numConfigs / 2,
},
{
desc: "retrieve search by name",
owner: config.Owner,
offset: 0,
limit: uint64(numConfigs),
filter: bootstrap.Filter{PartialMatch: map[string]string{"name": "1"}},
size: 1,
},
}
for _, tc := range cases {
ret := repo.RetrieveAll(tc.owner, tc.filter, tc.offset, tc.limit)

View File

@ -54,7 +54,8 @@ func migrateDB(db *sql.DB) error {
Up: []string{
`CREATE TABLE IF NOT EXISTS configs (
mainflux_thing TEXT UNIQUE NOT NULL,
owner VARCHAR(254),
owner VARCHAR(254),
name TEXT,
mainflux_key CHAR(36) UNIQUE NOT NULL,
mainflux_channels jsonb,
external_id TEXT UNIQUE NOT NULL,

View File

@ -59,11 +59,13 @@ func (r reader) ReadConfig(cfg Config) (mainflux.Response, error) {
for _, ch := range cfg.MFChannels {
channels = append(channels, channelRes{ID: ch.ID, Name: ch.Name, Metadata: ch.Metadata})
}
res := bootstrapRes{
MFKey: cfg.MFKey,
MFThing: cfg.MFThing,
MFChannels: channels,
Content: cfg.Content,
}
return res, nil
}

View File

@ -135,6 +135,7 @@ func (bs bootstrapService) Add(key string, cfg Config) (Config, error) {
bs.configs.RemoveUnknown(cfg.ExternalKey, cfg.ExternalID)
cfg.MFThing = id
return cfg, nil
}
@ -143,6 +144,7 @@ func (bs bootstrapService) View(key, id string) (Config, error) {
if err != nil {
return Config{}, err
}
return bs.configs.RetrieveByID(owner, id)
}
@ -208,10 +210,8 @@ func (bs bootstrapService) List(key string, filter Filter, offset, limit uint64)
if err != nil {
return []Config{}, err
}
if filter == nil {
return []Config{}, ErrMalformedEntity
}
if _, ok := filter["unknown"]; ok {
if filter.Unknown {
return bs.configs.RetrieveUnknown(offset, limit), nil
}

View File

@ -250,6 +250,7 @@ func TestList(t *testing.T) {
id := uuid.NewV4().String()
c.ExternalID = id
c.ExternalKey = id
c.Name = fmt.Sprintf("%s-%d", config.Name, i)
s, err := svc.Add(validToken, c)
saved = append(saved, s)
require.Nil(t, err, fmt.Sprintf("Saving config expected to succeed: %s.\n", err))
@ -274,7 +275,7 @@ func TestList(t *testing.T) {
err error
}{
{
desc: "list config",
desc: "list configs",
config: saved[0:10],
filter: bootstrap.Filter{},
key: validToken,
@ -283,7 +284,16 @@ func TestList(t *testing.T) {
err: nil,
},
{
desc: "list config unauthorized",
desc: "list configs with specified name",
config: saved[95:96],
filter: bootstrap.Filter{PartialMatch: map[string]string{"name": "95"}},
key: validToken,
offset: 0,
limit: 100,
err: nil,
},
{
desc: "list configs unauthorized",
config: []bootstrap.Config{},
filter: bootstrap.Filter{},
key: invalidToken,
@ -291,15 +301,6 @@ func TestList(t *testing.T) {
limit: 10,
err: bootstrap.ErrUnauthorizedAccess,
},
{
desc: "list config with invalid filter",
config: []bootstrap.Config{},
filter: nil,
key: validToken,
offset: 0,
limit: 10,
err: bootstrap.ErrMalformedEntity,
},
{
desc: "list last page",
config: saved[95:],
@ -310,18 +311,18 @@ func TestList(t *testing.T) {
err: nil,
},
{
desc: "list config with Active staate",
desc: "list configs with Active staate",
config: []bootstrap.Config{saved[41]},
filter: bootstrap.Filter{"state": bootstrap.Active.String()},
filter: bootstrap.Filter{FullMatch: map[string]string{"state": bootstrap.Active.String()}},
key: validToken,
offset: 35,
limit: 20,
err: nil,
},
{
desc: "list unknown config",
desc: "list unknown configs",
config: []bootstrap.Config{unknownConfig},
filter: bootstrap.Filter{"unknown": "true"},
filter: bootstrap.Filter{Unknown: true},
key: validToken,
offset: 0,
limit: 20,

View File

@ -53,6 +53,7 @@ paths:
- $ref: "#/parameters/Limit"
- $ref: "#/parameters/Offset"
- $ref: "#/parameters/State"
- $ref: "#/parameters/Name"
responses:
200:
description: Data retrieved.
@ -263,6 +264,12 @@ parameters:
- inactive
- active
required: false
Name:
name: name
description: Name of the config. Search by name is partial-match and case-insensitive.
in: query
type: string
required: false
responses:
ServiceError: