Skip to content

Commit

Permalink
Reorganize web/contact
Browse files Browse the repository at this point in the history
  • Loading branch information
rowanseymour committed Sep 1, 2020
1 parent b621766 commit 029fbd6
Show file tree
Hide file tree
Showing 4 changed files with 391 additions and 365 deletions.
203 changes: 0 additions & 203 deletions web/contact/contact.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ import (
"encoding/json"
"net/http"

"github.com/nyaruka/goflow/assets"
"github.com/nyaruka/goflow/contactql"
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/goflow/utils"
"github.com/nyaruka/mailroom/goflow"
Expand All @@ -17,211 +15,10 @@ import (
)

func init() {
web.RegisterJSONRoute(http.MethodPost, "/mr/contact/search", web.RequireAuthToken(handleSearch))
web.RegisterJSONRoute(http.MethodPost, "/mr/contact/parse_query", web.RequireAuthToken(handleParseQuery))
web.RegisterJSONRoute(http.MethodPost, "/mr/contact/create", web.RequireAuthToken(handleCreate))
web.RegisterJSONRoute(http.MethodPost, "/mr/contact/modify", web.RequireAuthToken(handleModify))
}

// Searches the contacts for an org
//
// {
// "org_id": 1,
// "group_uuid": "985a83fe-2e9f-478d-a3ec-fa602d5e7ddd",
// "query": "age > 10",
// "sort": "-age"
// }
//
type searchRequest struct {
OrgID models.OrgID `json:"org_id" validate:"required"`
GroupUUID assets.GroupUUID `json:"group_uuid" validate:"required"`
Query string `json:"query"`
PageSize int `json:"page_size"`
Offset int `json:"offset"`
Sort string `json:"sort"`
}

// Response for a contact search
//
// {
// "query": "age > 10",
// "contact_ids": [5,10,15],
// "total": 3,
// "offset": 0,
// "metadata": {
// "fields": [
// {"key": "age", "name": "Age"}
// ],
// "allow_as_group": true
// }
// }
type searchResponse struct {
Query string `json:"query"`
ContactIDs []models.ContactID `json:"contact_ids"`
Total int64 `json:"total"`
Offset int `json:"offset"`
Sort string `json:"sort"`
Metadata *contactql.Inspection `json:"metadata,omitempty"`

// deprecated
Fields []string `json:"fields"`
AllowAsGroup bool `json:"allow_as_group"`
}

// handles a contact search request
func handleSearch(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) {
request := &searchRequest{
Offset: 0,
PageSize: 50,
Sort: "-id",
}
if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil {
return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil
}

// grab our org assets
oa, err := models.GetOrgAssetsWithRefresh(s.CTX, s.DB, request.OrgID, models.RefreshFields|models.RefreshGroups)
if err != nil {
return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets")
}

// Perform our search
parsed, hits, total, err := models.ContactIDsForQueryPage(ctx, s.ElasticClient, oa,
request.GroupUUID, request.Query, request.Sort, request.Offset, request.PageSize)

if err != nil {
isQueryError, qerr := contactql.IsQueryError(err)
if isQueryError {
return qerr, http.StatusBadRequest, nil
}
return nil, http.StatusInternalServerError, err
}

// normalize and inspect the query
normalized := ""
var metadata *contactql.Inspection
allowAsGroup := false
fields := make([]string, 0)

if parsed != nil {
normalized = parsed.String()
metadata = contactql.Inspect(parsed)
fields = append(fields, metadata.Attributes...)
for _, f := range metadata.Fields {
fields = append(fields, f.Key)
}
allowAsGroup = metadata.AllowAsGroup
}

// build our response
response := &searchResponse{
Query: normalized,
ContactIDs: hits,
Total: total,
Offset: request.Offset,
Sort: request.Sort,
Metadata: metadata,
Fields: fields,
AllowAsGroup: allowAsGroup,
}

return response, http.StatusOK, nil
}

// Request to parse the passed in query
//
// {
// "org_id": 1,
// "query": "age > 10",
// "group_uuid": "123123-123-123-"
// }
//
type parseRequest struct {
OrgID models.OrgID `json:"org_id" validate:"required"`
Query string `json:"query" validate:"required"`
GroupUUID assets.GroupUUID `json:"group_uuid"`
}

// Response for a parse query request
//
// {
// "query": "age > 10",
// "elastic_query": { .. },
// "metadata": {
// "fields": [
// {"key": "age", "name": "Age"}
// ],
// "allow_as_group": true
// }
// }
type parseResponse struct {
Query string `json:"query"`
ElasticQuery interface{} `json:"elastic_query"`
Metadata *contactql.Inspection `json:"metadata,omitempty"`

// deprecated
Fields []string `json:"fields"`
AllowAsGroup bool `json:"allow_as_group"`
}

// handles a query parsing request
func handleParseQuery(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) {
request := &parseRequest{}
if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil {
return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil
}

// grab our org assets
oa, err := models.GetOrgAssetsWithRefresh(s.CTX, s.DB, request.OrgID, models.RefreshFields|models.RefreshGroups)
if err != nil {
return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets")
}

env := oa.Env()
parsed, err := contactql.ParseQuery(env, request.Query, oa.SessionAssets())

if err != nil {
isQueryError, qerr := contactql.IsQueryError(err)
if isQueryError {
return qerr, http.StatusBadRequest, nil
}
return nil, http.StatusInternalServerError, err
}

// normalize and inspect the query
normalized := ""
var metadata *contactql.Inspection
allowAsGroup := false
fields := make([]string, 0)

if parsed != nil {
normalized = parsed.String()
metadata = contactql.Inspect(parsed)
fields = append(fields, metadata.Attributes...)
for _, f := range metadata.Fields {
fields = append(fields, f.Key)
}
allowAsGroup = metadata.AllowAsGroup
}

eq := models.BuildElasticQuery(oa, request.GroupUUID, parsed)
eqj, err := eq.Source()
if err != nil {
return nil, http.StatusInternalServerError, err
}

// build our response
response := &parseResponse{
Query: normalized,
ElasticQuery: eqj,
Metadata: metadata,
Fields: fields,
AllowAsGroup: allowAsGroup,
}

return response, http.StatusOK, nil
}

// Request to create a new contact.
//
// {
Expand Down
162 changes: 0 additions & 162 deletions web/contact/contact_test.go
Original file line number Diff line number Diff line change
@@ -1,178 +1,16 @@
package contact

import (
"bytes"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"sync"
"testing"
"time"

"github.com/nyaruka/goflow/utils/uuids"
"github.com/nyaruka/mailroom/config"
_ "github.com/nyaruka/mailroom/hooks"
"github.com/nyaruka/mailroom/models"
"github.com/nyaruka/mailroom/testsuite"
"github.com/nyaruka/mailroom/web"

"github.com/olivere/elastic"
"github.com/stretchr/testify/assert"
)

func TestSearch(t *testing.T) {
testsuite.Reset()
ctx := testsuite.CTX()
db := testsuite.DB()
rp := testsuite.RP()
wg := &sync.WaitGroup{}

es := testsuite.NewMockElasticServer()
defer es.Close()

client, err := elastic.NewClient(
elastic.SetURL(es.URL()),
elastic.SetHealthcheck(false),
elastic.SetSniff(false),
)
assert.NoError(t, err)

server := web.NewServer(ctx, config.Mailroom, db, rp, nil, client, wg)
server.Start()

// give our server time to start
time.Sleep(time.Second)

defer server.Stop()

singleESResponse := fmt.Sprintf(`{
"_scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAbgc0WS1hqbHlfb01SM2lLTWJRMnVOSVZDdw==",
"took": 2,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": 1,
"max_score": null,
"hits": [
{
"_index": "contacts",
"_type": "_doc",
"_id": "%d",
"_score": null,
"_routing": "1",
"sort": [
15124352
]
}
]
}
}`, models.CathyID)

tcs := []struct {
URL string
Method string
Body string
Status int
Error string
Hits []models.ContactID
Query string
Fields []string
ESResponse string
}{
{"/mr/contact/search", "GET", "", 405, "illegal method: GET", nil, "", nil, ""},
{
"/mr/contact/search", "POST",
fmt.Sprintf(`{"org_id": 1, "query": "birthday = tomorrow", "group_uuid": "%s"}`, models.AllContactsGroupUUID),
400, "can't resolve 'birthday' to attribute, scheme or field",
nil, "", nil, "",
},
{
"/mr/contact/search", "POST",
fmt.Sprintf(`{"org_id": 1, "query": "age > tomorrow", "group_uuid": "%s"}`, models.AllContactsGroupUUID),
400, "can't convert 'tomorrow' to a number",
nil, "", nil, "",
},
{
"/mr/contact/search", "POST",
fmt.Sprintf(`{"org_id": 1, "query": "Cathy", "group_uuid": "%s"}`, models.AllContactsGroupUUID),
200,
"",
[]models.ContactID{models.CathyID},
`name ~ "Cathy"`,
[]string{"name"},
singleESResponse,
},
{
"/mr/contact/search", "POST",
fmt.Sprintf(`{"org_id": 1, "query": "AGE = 10 and gender = M", "group_uuid": "%s"}`, models.AllContactsGroupUUID),
200,
"",
[]models.ContactID{models.CathyID},
`age = 10 AND gender = "M"`,
[]string{"age", "gender"},
singleESResponse,
},
{
"/mr/contact/search", "POST",
fmt.Sprintf(`{"org_id": 1, "query": "", "group_uuid": "%s"}`, models.AllContactsGroupUUID),
200,
"",
[]models.ContactID{models.CathyID},
``,
[]string{},
singleESResponse,
},
}

for i, tc := range tcs {
var body io.Reader
es.NextResponse = tc.ESResponse

if tc.Body != "" {
body = bytes.NewReader([]byte(tc.Body))
}

req, err := http.NewRequest(tc.Method, "http://localhost:8090"+tc.URL, body)
assert.NoError(t, err, "%d: error creating request", i)

resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err, "%d: error making request", i)

assert.Equal(t, tc.Status, resp.StatusCode, "%d: unexpected status", i)

content, err := ioutil.ReadAll(resp.Body)
assert.NoError(t, err, "%d: error reading body", i)

// on 200 responses parse them
if resp.StatusCode == 200 {
r := &searchResponse{}
err = json.Unmarshal(content, r)
assert.NoError(t, err)
assert.Equal(t, tc.Hits, r.ContactIDs)
assert.Equal(t, tc.Query, r.Query)
assert.Equal(t, tc.Fields, r.Fields)
} else {
r := &web.ErrorResponse{}
err = json.Unmarshal(content, r)
assert.NoError(t, err)
assert.Equal(t, tc.Error, r.Error)
}
}
}

func TestParse(t *testing.T) {
testsuite.Reset()

web.RunWebTests(t, "testdata/parse_query.json")
}

func TestCreateContacts(t *testing.T) {
testsuite.Reset()
db := testsuite.DB()
Expand Down
Loading

0 comments on commit 029fbd6

Please sign in to comment.