diff --git a/api/handler.go b/api/handler.go index cd5afc2..96e5baf 100644 --- a/api/handler.go +++ b/api/handler.go @@ -65,6 +65,8 @@ func Handler() *mux.Router { m.Get(router.UpdateUnitType).Handler(handler(serveUpdateUnitType)) m.Get(router.DeleteUnitType).Handler(handler(serveDeleteUnitType)) + m.Get(router.Measurement).Handler(handler(serveMeasurement)) + return m } diff --git a/api/measurements.go b/api/measurements.go new file mode 100644 index 0000000..5aa9018 --- /dev/null +++ b/api/measurements.go @@ -0,0 +1,22 @@ +package api + +import ( + "net/http" + "strconv" + + "github.com/gorilla/mux" +) + +func serveMeasurement(w http.ResponseWriter, r *http.Request) error { + id, err := strconv.ParseInt(mux.Vars(r)["Id"], 10, 0) + if err != nil { + return err + } + + measurement, err := store.Measurements.Get(id) + if err != nil { + return err + } + + return writeJSON(w, measurement) +} diff --git a/api/measurements_test.go b/api/measurements_test.go new file mode 100644 index 0000000..631b3fd --- /dev/null +++ b/api/measurements_test.go @@ -0,0 +1,40 @@ +package api + +import ( + "testing" + + "github.com/thermokarst/bactdb/models" +) + +func newMeasurement() *models.Measurement { + measurement := models.NewMeasurement() + return measurement +} + +func TestMeasurement_Get(t *testing.T) { + setup() + + want := newMeasurement() + + calledGet := false + + store.Measurements.(*models.MockMeasurementsService).Get_ = func(id int64) (*models.Measurement, error) { + if id != want.Id { + t.Errorf("wanted request for measurement %d but got %d", want.Id, id) + } + calledGet = true + return want, nil + } + + got, err := apiClient.Measurements.Get(want.Id) + if err != nil { + t.Fatal(err) + } + + if !calledGet { + t.Error("!calledGet") + } + if !normalizeDeepEqual(want, got) { + t.Errorf("got %+v but wanted %+v", got, want) + } +} diff --git a/datastore/datastore.go b/datastore/datastore.go index 944d0c4..7ad7b70 100644 --- a/datastore/datastore.go +++ b/datastore/datastore.go @@ -17,6 +17,7 @@ type Datastore struct { Observations models.ObservationsService TextMeasurementTypes models.TextMeasurementTypesService UnitTypes models.UnitTypesService + Measurements models.MeasurementsService dbh modl.SqlExecutor } @@ -41,6 +42,7 @@ func NewDatastore(dbh modl.SqlExecutor) *Datastore { d.Observations = &observationsStore{d} d.TextMeasurementTypes = &textMeasurementTypesStore{d} d.UnitTypes = &unitTypesStore{d} + d.Measurements = &measurementsStore{d} return d } @@ -54,5 +56,6 @@ func NewMockDatastore() *Datastore { Observations: &models.MockObservationsService{}, TextMeasurementTypes: &models.MockTextMeasurementTypesService{}, UnitTypes: &models.MockUnitTypesService{}, + Measurements: &models.MockMeasurementsService{}, } } diff --git a/datastore/measurements.go b/datastore/measurements.go new file mode 100644 index 0000000..6a75d57 --- /dev/null +++ b/datastore/measurements.go @@ -0,0 +1,22 @@ +package datastore + +import "github.com/thermokarst/bactdb/models" + +func init() { + DB.AddTableWithName(models.Measurement{}, "measurements").SetKeys(true, "Id") +} + +type measurementsStore struct { + *Datastore +} + +func (s *measurementsStore) Get(id int64) (*models.Measurement, error) { + var measurement []*models.Measurement + if err := s.dbh.Select(&measurement, `SELECT * FROM measurements WHERE id=$1;`, id); err != nil { + return nil, err + } + if len(measurement) == 0 { + return nil, models.ErrMeasurementNotFound + } + return measurement[0], nil +} diff --git a/datastore/measurements_test.go b/datastore/measurements_test.go new file mode 100644 index 0000000..5b6c2ba --- /dev/null +++ b/datastore/measurements_test.go @@ -0,0 +1,57 @@ +package datastore + +import ( + "database/sql" + "reflect" + "testing" + + "github.com/jmoiron/modl" + "github.com/thermokarst/bactdb/models" +) + +func insertMeasurement(t *testing.T, tx *modl.Transaction) *models.Measurement { + // clean up our target table + tx.Exec(`DELETE FROM measurements;`) + measurement := newMeasurement(t, tx) + if err := tx.Insert(measurement); err != nil { + t.Fatal(err) + } + return measurement +} + +func newMeasurement(t *testing.T, tx *modl.Transaction) *models.Measurement { + // we have a few things to take care of first... + strain := insertStrain(t, tx) + observation := insertObservation(t, tx) + + // we want to create and insert a unit type record, too. + unit_type := insertUnitType(t, tx) + + return &models.Measurement{ + StrainId: strain.Id, + ObservationId: observation.Id, + MeasurementValue: sql.NullFloat64{Float64: 1.23, Valid: true}, + UnitTypeId: sql.NullInt64{Int64: unit_type.Id, Valid: true}, + } +} + +func TestMeasurementsStore_Get_db(t *testing.T) { + tx, _ := DB.Begin() + defer tx.Rollback() + + want := insertMeasurement(t, tx) + + d := NewDatastore(tx) + + measurement, err := d.Measurements.Get(want.Id) + if err != nil { + t.Fatal(err) + } + + normalizeTime(&want.CreatedAt, &want.UpdatedAt, &want.DeletedAt) + normalizeTime(&measurement.CreatedAt, &measurement.UpdatedAt, &measurement.DeletedAt) + + if !reflect.DeepEqual(measurement, want) { + t.Errorf("got measurement %+v, want %+v", measurement, want) + } +} diff --git a/models/client.go b/models/client.go index 18420f7..6feaeaa 100644 --- a/models/client.go +++ b/models/client.go @@ -24,6 +24,7 @@ type Client struct { Observations ObservationsService TextMeasurementTypes TextMeasurementTypesService UnitTypes UnitTypesService + Measurements MeasurementsService // BaseURL for HTTP requests to bactdb's API. BaseURL *url.URL @@ -59,6 +60,7 @@ func NewClient(httpClient *http.Client) *Client { c.Observations = &observationsService{c} c.TextMeasurementTypes = &textMeasurementTypesService{c} c.UnitTypes = &unitTypesService{c} + c.Measurements = &measurementsService{c} return c } diff --git a/models/measurements.go b/models/measurements.go new file mode 100644 index 0000000..444eae3 --- /dev/null +++ b/models/measurements.go @@ -0,0 +1,82 @@ +package models + +import ( + "database/sql" + "errors" + "strconv" + "time" + + "github.com/lib/pq" + "github.com/thermokarst/bactdb/router" +) + +// A Measurement is the main data type for this application +// There are two types of supported measurements: text & numerical. The table +// has a constraint that will allow one or the other for a particular +// combination of strain & observation, but not both. +type Measurement struct { + Id int64 `json:"id,omitempty"` + StrainId int64 `db:"strain_id" json:"strainId"` + ObservationId int64 `db:"observation_id" json:"observationId"` + TextMeasurementTypeId sql.NullInt64 `db:"text_measurement_type_id" json:"textMeasurementTypeId"` + MeasurementValue sql.NullFloat64 `db:"measurement_value" json:"measurementValue"` + ConfidenceInterval sql.NullFloat64 `db:"confidence_interval" json:"confidenceInterval"` + UnitTypeId sql.NullInt64 `db:"unit_type_id" json:"unitTypeId"` + CreatedAt time.Time `db:"created_at" json:"createdAt"` + UpdatedAt time.Time `db:"updated_at" json:"updatedAt"` + DeletedAt pq.NullTime `db:"deleted_at" json:"deletedAt"` +} + +func NewMeasurement() *Measurement { + return &Measurement{ + MeasurementValue: sql.NullFloat64{Float64: 1.23, Valid: true}, + } +} + +type MeasurementsService interface { + // Get a measurement + Get(id int64) (*Measurement, error) +} + +var ( + ErrMeasurementNotFound = errors.New("measurement not found") +) + +type measurementsService struct { + client *Client +} + +func (s *measurementsService) Get(id int64) (*Measurement, error) { + strId := strconv.FormatInt(id, 10) + + url, err := s.client.url(router.Measurement, map[string]string{"Id": strId}, nil) + if err != nil { + return nil, err + } + + req, err := s.client.NewRequest("GET", url.String(), nil) + if err != nil { + return nil, err + } + + var measurement *Measurement + _, err = s.client.Do(req, &measurement) + if err != nil { + return nil, err + } + + return measurement, nil +} + +type MockMeasurementsService struct { + Get_ func(id int64) (*Measurement, error) +} + +var _ MeasurementsService = &MockMeasurementsService{} + +func (s *MockMeasurementsService) Get(id int64) (*Measurement, error) { + if s.Get_ == nil { + return nil, nil + } + return s.Get_(id) +} diff --git a/models/measurements_test.go b/models/measurements_test.go new file mode 100644 index 0000000..f7856ed --- /dev/null +++ b/models/measurements_test.go @@ -0,0 +1,45 @@ +package models + +import ( + "net/http" + "reflect" + "testing" + + "github.com/thermokarst/bactdb/router" +) + +func newMeasurement() *Measurement { + measurement := NewMeasurement() + measurement.Id = 1 + return measurement +} + +func TestMeasurementService_Get(t *testing.T) { + setup() + defer teardown() + + want := newMeasurement() + + var called bool + mux.HandleFunc(urlPath(t, router.Measurement, map[string]string{"Id": "1"}), func(w http.ResponseWriter, r *http.Request) { + called = true + testMethod(t, r, "GET") + + writeJSON(w, want) + }) + + measurement, err := client.Measurements.Get(want.Id) + if err != nil { + t.Errorf("Measurements.Get returned error: %v", err) + } + + if !called { + t.Fatal("!called") + } + + normalizeTime(&want.CreatedAt, &want.UpdatedAt, &want.DeletedAt) + + if !reflect.DeepEqual(measurement, want) { + t.Errorf("Measurements.Get return %+v, want %+v", measurement, want) + } +} diff --git a/router/api.go b/router/api.go index 7144f39..177e387 100644 --- a/router/api.go +++ b/router/api.go @@ -59,5 +59,8 @@ func API() *mux.Router { m.Path("/unit_types/{Id:.+}").Methods("PUT").Name(UpdateUnitType) m.Path("/unit_types/{Id:.+}").Methods("DELETE").Name(DeleteUnitType) + // Measurements + m.Path("/measurements/{Id:.+}").Methods("GET").Name(Measurement) + return m } diff --git a/router/routes.go b/router/routes.go index 0966496..75815a2 100644 --- a/router/routes.go +++ b/router/routes.go @@ -46,4 +46,6 @@ const ( UnitTypes = "unit_type:list" UpdateUnitType = "unit_type:update" DeleteUnitType = "unit_type:delete" + + Measurement = "measurement:get" )