Skip to content

Commit

Permalink
add metrics capture feature (#27)
Browse files Browse the repository at this point in the history
Signed-off-by: Kathurima Kimathi <kathurimakimathi415@gmail.com>
  • Loading branch information
KathurimaKimathi committed Oct 14, 2021
1 parent 4c4133d commit f21f502
Show file tree
Hide file tree
Showing 25 changed files with 1,087 additions and 4 deletions.
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ require (
go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.21.0
go.opentelemetry.io/otel v1.0.0-RC1
go.opentelemetry.io/otel/trace v1.0.0-RC1
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519
gorm.io/datatypes v1.0.2
gorm.io/driver/postgres v1.1.2
gorm.io/gorm v1.21.16
)
237 changes: 237 additions & 0 deletions go.sum

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions pkg/onboarding/application/dto/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ package dto

import (
"net/url"
"time"

"github.com/savannahghi/enumutils"
"github.com/savannahghi/onboarding-service/pkg/onboarding/domain"
"gorm.io/datatypes"
)

// FacilityInput describes the facility input
Expand Down Expand Up @@ -62,3 +65,21 @@ func (i *FacilitySortInput) ToURLValues() (values url.Values) {
}
return vals
}

// MetricInput reprents the metrics data structure input
type MetricInput struct {

// TODO Metric types should be a controlled list i.e enum
Type domain.MetricType `json:"metric_type"`

// this will vary by context
// should not identify the user (there's a UID field)
// focus on the actual event
Payload datatypes.JSON `gorm:"column:payload"`

Timestamp time.Time `json:"time"`

// a user identifier, can be hashed for anonymity
// with a predictable one way hash
UID string `json:"uid"`
}
34 changes: 34 additions & 0 deletions pkg/onboarding/application/utils/helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package utils

import (
"hash"

"github.com/savannahghi/onboarding-service/pkg/onboarding/infrastructure"
)

// Options is a struct for custom values of salt length, number of iterations, the encoded key's length,
// and the hash function being used. If set to `nil`, default options are used:
// &Options{ 256, 10000, 512, "sha512" }
type Options struct {
SaltLen int
Iterations int
KeyLen int
HashFunction func() hash.Hash
}

// EncryptUID takes two arguments, a raw uid, and a pointer to an Options struct.
// In order to use default options, pass `nil` as the second argument.
// It returns the generated salt and encoded key for the user.
func EncryptUID(rawUID string, options *Options) (string, string) {
interactor := infrastructure.NewInteractor()
return interactor.PINExtension.EncryptPIN(rawUID, nil)
}

// CompareUID takes four arguments, the raw UID, its generated salt, the encoded UID,
// and a pointer to the Options struct, and returns a boolean value determining whether the UID is the correct one or not.
// Passing `nil` as the last argument resorts to default options.
func CompareUID(rawUID string, salt string, encodedUID string, options *Options) bool {

interactor := infrastructure.NewInteractor()
return interactor.PINExtension.ComparePIN(rawUID, salt, encodedUID, nil)
}
143 changes: 143 additions & 0 deletions pkg/onboarding/application/utils/helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package utils

import (
"encoding/hex"
"reflect"
"testing"

"github.com/savannahghi/onboarding/pkg/onboarding/application/extension"
"github.com/tj/assert"
)

const (
// DefaultSaltLen is the length of generated salt for the user is 256
DefaultSaltLen = 256
// DefaultKeyLen is the length of encoded key in PBKDF2 function is 512
DefaultKeyLen = 512
)

func TestEncryptUID(t *testing.T) {
type args struct {
rawUID string
options *Options
}

customOptions := Options{
// salt length should be greater than 0
SaltLen: 0,
Iterations: 2,
KeyLen: 1,
HashFunction: extension.DefaultHashFunction,
}
tests := []struct {
name string
args args
want string
want1 string
wantError bool
}{
{
name: "success: correct default options have been used to encrypt uid",
args: args{
rawUID: "1235",
options: nil,
},
wantError: false,
},
{
name: "failure: incorrect custom options have been used to encrypt uid",
args: args{
rawUID: "1235",
options: &customOptions,
},
wantError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
salt, encoded := EncryptUID(tt.args.rawUID, tt.args.options)
if tt.wantError {
encodedBytes, err := hex.DecodeString(encoded)
if err != nil {
t.Error("Encrypted uid not hex encoded properly")
}
assert.Equal(t, len(encodedBytes), DefaultKeyLen)
}
if !tt.wantError {
if !reflect.DeepEqual(len([]byte(salt)), DefaultSaltLen) {
t.Error("Received length of salt:", len([]byte(salt)), "Expected length of salt:", DefaultSaltLen)
return
}
encodedBytes, err := hex.DecodeString(encoded)
if err != nil {
t.Error("Encrypted uid not hex encoded properly")
}
assert.Equal(t, len(encodedBytes), DefaultKeyLen)
}

})
}
}

func TestCompareUID(t *testing.T) {
salt, encoded := EncryptUID("1234", nil)
type args struct {
rawUID string
salt string
encodedUID string
options *Options
}
tests := []struct {
name string
args args
want bool
wantError bool
}{
{
name: "success: correct uid supplied that has been encrypted correctly",
args: args{
rawUID: "1234", // this is the same uid that was encrypted
salt: salt,
encodedUID: encoded,
options: nil,
},
want: true,
wantError: false,
},
{
name: "failure: incorrect uid supplied that has been encrypted correctly",
args: args{
rawUID: "4567", // this uid was never encrypted
salt: salt,
encodedUID: encoded,
options: nil,
},
want: false,
wantError: true,
},
{
name: "failure: wrong custom options have been used to encrypt uid",
args: args{
rawUID: "12345",
salt: "some random salt",
encodedUID: "uncoded string",
options: nil,
},
want: false,
wantError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isEncypted := CompareUID(tt.args.rawUID, tt.args.salt, tt.args.encodedUID, tt.args.options)
if !tt.wantError {
assert.True(t, isEncypted)
assert.Equal(t, tt.want, isEncypted)
}
if tt.wantError {
assert.False(t, isEncypted)
assert.Equal(t, tt.want, isEncypted)
}
})
}
}
61 changes: 61 additions & 0 deletions pkg/onboarding/domain/enum.go
Original file line number Diff line number Diff line change
@@ -1 +1,62 @@
package domain

import (
"fmt"
"io"
"log"
"strconv"
)

// MetricType is a list of all the metrics type to be colected.
type MetricType string

// metrics type constants
const (
EngagementMetrics MetricType = "Engagement"
SatisfactionMetrics MetricType = "Satisfaction"
UserInteractionMetrics MetricType = "UserInteraction"
PerformanceMetrics MetricType = "Performance"
)

// AllMetrics is a set of a valid and known metric types.
var AllMetrics = []MetricType{
EngagementMetrics,
SatisfactionMetrics,
UserInteractionMetrics,
PerformanceMetrics,
}

// IsValid returns true if a metric is valid
func (m MetricType) IsValid() bool {
switch m {
case EngagementMetrics, SatisfactionMetrics, UserInteractionMetrics, PerformanceMetrics:
return true
}
return false
}

func (m MetricType) String() string {
return string(m)
}

// UnmarshalGQL converts the supplied value to a metric type.
func (m *MetricType) UnmarshalGQL(v interface{}) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
}

*m = MetricType(str)
if !m.IsValid() {
return fmt.Errorf("%s is not a valid MetricType", str)
}
return nil
}

// MarshalGQL writes the metric type to the supplied writer
func (m MetricType) MarshalGQL(w io.Writer) {
_, err := fmt.Fprint(w, strconv.Quote(m.String()))
if err != nil {
log.Printf("%v\n", err)
}
}
27 changes: 26 additions & 1 deletion pkg/onboarding/domain/models.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package domain

import "github.com/google/uuid"
import (
"time"

"github.com/google/uuid"
"gorm.io/datatypes"
)

// Facility models the details of healthcare facilities that are on the platform.
//
Expand Down Expand Up @@ -32,3 +37,23 @@ type Facility struct {
// DataType string // TODO: Ideally a controlled list i.e enum
// Date string // TODO: Clear spec on validation e.g dates must be ISO 8601
// }

// Metric reprents the metrics data structure input
type Metric struct {
// ensures we don't re-save the same metric; opaque; globally unique
MetricID uuid.UUID

// TODO Metric types should be a controlled list i.e enum
Type MetricType

// this will vary by context
// should not identify the user (there's a UID field)
// focus on the actual event
Payload datatypes.JSON `gorm:"column:payload"`

Timestamp time.Time

// a user identifier, can be hashed for anonymity
// with a predictable one way hash
UID string
}
12 changes: 12 additions & 0 deletions pkg/onboarding/infrastructure/database/postgres/gorm/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
// Create contains all the methods used to perform a create operation in DB
type Create interface {
CreateFacility(ctx context.Context, facility *Facility) (*Facility, error)
CollectMetrics(ctx context.Context, metrics *Metric) (*Metric, error)
}

// CreateFacility ...
Expand All @@ -20,3 +21,14 @@ func (db *PGInstance) CreateFacility(ctx context.Context, facility *Facility) (*

return facility, nil
}

// CollectMetrics takes the collected metrics and saves them in the database.
func (db *PGInstance) CollectMetrics(ctx context.Context, metrics *Metric) (*Metric, error) {
err := db.DB.Create(metrics).Error

if err != nil {
return nil, fmt.Errorf("failed to create a facility: %v", err)
}

return metrics, nil
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ package mock

import (
"context"
"time"

"github.com/google/uuid"
"github.com/savannahghi/onboarding-service/pkg/onboarding/domain"
"github.com/savannahghi/onboarding-service/pkg/onboarding/infrastructure/database/postgres/gorm"
"github.com/segmentio/ksuid"
"gorm.io/datatypes"
)

// GormMock struct implements mocks of `gorm's`internal methods.
Expand All @@ -15,6 +19,7 @@ type GormMock struct {
RetrieveFacilityFn func(ctx context.Context, id *uuid.UUID) (*gorm.Facility, error)
GetFacilitiesFn func(ctx context.Context) ([]gorm.Facility, error)
DeleteFacilityFn func(ctx context.Context, mfl_code string) (bool, error)
CollectMetricsFn func(ctx context.Context, metrics *gorm.Metric) (*gorm.Metric, error)
}

// NewGormMock initializes a new instance of `GormMock` then mocking the case of success.
Expand Down Expand Up @@ -72,6 +77,18 @@ func NewGormMock() *GormMock {
DeleteFacilityFn: func(ctx context.Context, mfl_code string) (bool, error) {
return true, nil
},

CollectMetricsFn: func(ctx context.Context, metrics *gorm.Metric) (*gorm.Metric, error) {
now := time.Now()
metricID := uuid.New()
return &gorm.Metric{
MetricID: &metricID,
Type: domain.EngagementMetrics,
Payload: datatypes.JSON([]byte(`{"who": "test user", "keyword": "suicidal"}`)),
Timestamp: now,
UID: ksuid.New().String(),
}, nil
},
}
}

Expand All @@ -94,3 +111,8 @@ func (gm *GormMock) GetFacilities(ctx context.Context) ([]gorm.Facility, error)
func (gm *GormMock) DeleteFacility(ctx context.Context, mflcode string) (bool, error) {
return gm.DeleteFacilityFn(ctx, mflcode)
}

// CollectMetrics mocks the implementation of CollectMetrics method.
func (gm *GormMock) CollectMetrics(ctx context.Context, metrics *gorm.Metric) (*gorm.Metric, error) {
return gm.CollectMetricsFn(ctx, metrics)
}
Loading

0 comments on commit f21f502

Please sign in to comment.