diff --git a/Godeps/Godeps.json b/Godeps/Godeps.json index 1dcd5db..7644a80 100644 --- a/Godeps/Godeps.json +++ b/Godeps/Godeps.json @@ -1,9 +1,6 @@ { "ImportPath": "github.com/thermokarst/bactdb", "GoVersion": "go1.5", - "Packages": [ - "./..." - ], "Deps": [ { "ImportPath": "github.com/DavidHuie/gomigrate", diff --git a/api/characteristics.go b/api/characteristics.go new file mode 100644 index 0000000..0d284db --- /dev/null +++ b/api/characteristics.go @@ -0,0 +1,196 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/url" + + "github.com/thermokarst/bactdb/errors" + "github.com/thermokarst/bactdb/helpers" + "github.com/thermokarst/bactdb/models" + "github.com/thermokarst/bactdb/payloads" + "github.com/thermokarst/bactdb/types" +) + +// CharacteristicService provides for CRUD operations +type CharacteristicService struct{} + +// Unmarshal satisfies interface Updater and interface Creater +func (c CharacteristicService) Unmarshal(b []byte) (types.Entity, error) { + var cj payloads.Characteristic + err := json.Unmarshal(b, &cj) + return &cj, err +} + +// List lists all characteristics +func (c CharacteristicService) List(val *url.Values, claims *types.Claims) (types.Entity, *types.AppError) { + if val == nil { + return nil, newJSONError(errors.ErrMustProvideOptions, http.StatusInternalServerError) + } + var opt helpers.ListOptions + if err := helpers.SchemaDecoder.Decode(&opt, *val); err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + characteristics, err := models.ListCharacteristics(opt, claims) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + strainsOpt, err := models.StrainOptsFromCharacteristics(opt) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + strains, err := models.ListStrains(*strainsOpt, claims) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + speciesOpt, err := models.SpeciesOptsFromStrains(*strainsOpt) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + species, err := models.ListSpecies(*speciesOpt, claims) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + measurementsOpt, err := models.MeasurementOptsFromCharacteristics(opt) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + measurements, err := models.ListMeasurements(*measurementsOpt, claims) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + payload := payloads.Characteristics{ + Characteristics: characteristics, + Measurements: measurements, + Strains: strains, + Species: species, + Meta: &models.CharacteristicMeta{ + CanAdd: helpers.CanAdd(claims), + }, + } + + return &payload, nil +} + +// Get retrieves a single characteristic +func (c CharacteristicService) Get(id int64, genus string, claims *types.Claims) (types.Entity, *types.AppError) { + characteristic, err := models.GetCharacteristic(id, genus, claims) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + strains, strainOpts, err := models.StrainsFromCharacteristicID(id, genus, claims) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + speciesOpt, err := models.SpeciesOptsFromStrains(*strainOpts) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + species, err := models.ListSpecies(*speciesOpt, claims) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + measurements, _, err := models.MeasurementsFromCharacteristicID(id, genus, claims) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + payload := payloads.Characteristic{ + Characteristic: characteristic, + Measurements: measurements, + Strains: strains, + Species: species, + } + + return &payload, nil +} + +// Update modifies an existing characteristic +func (c CharacteristicService) Update(id int64, e *types.Entity, genus string, claims *types.Claims) *types.AppError { + payload := (*e).(*payloads.Characteristic) + payload.Characteristic.UpdatedBy = claims.Sub + payload.Characteristic.ID = id + + // First, handle Characteristic Type + id, err := models.InsertOrGetCharacteristicType(payload.Characteristic.CharacteristicType, claims) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + + payload.Characteristic.CanEdit = helpers.CanEdit(claims, payload.Characteristic.CreatedBy) + + payload.Characteristic.CharacteristicTypeID = id + // TODO: fix this + count, err := models.DBH.Update(payload.Characteristic.CharacteristicBase) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + if count != 1 { + // TODO: fix this + return newJSONError(errors.ErrCharacteristicNotUpdated, http.StatusBadRequest) + } + + strains, strainOpts, err := models.StrainsFromCharacteristicID(id, genus, claims) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + + speciesOpt, err := models.SpeciesOptsFromStrains(*strainOpts) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + + species, err := models.ListSpecies(*speciesOpt, claims) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + + payload.Strains = strains + // TODO: tack on measurements + payload.Measurements = nil + payload.Species = species + + return nil +} + +// Create initializes a new characteristic +func (c CharacteristicService) Create(e *types.Entity, genus string, claims *types.Claims) *types.AppError { + payload := (*e).(*payloads.Characteristic) + payload.Characteristic.CreatedBy = claims.Sub + payload.Characteristic.UpdatedBy = claims.Sub + + id, err := models.InsertOrGetCharacteristicType(payload.Characteristic.CharacteristicType, claims) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + payload.Characteristic.CharacteristicTypeID = id + + // TODO: fix this + err = models.DBH.Insert(payload.Characteristic.CharacteristicBase) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + + characteristic, err := models.GetCharacteristic(payload.Characteristic.ID, genus, claims) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + + payload.Characteristic = characteristic + payload.Meta = &models.CharacteristicMeta{ + CanAdd: helpers.CanAdd(claims), + } + return nil +} diff --git a/compare.go b/api/compare.go similarity index 59% rename from compare.go rename to api/compare.go index 53d861e..bcb0721 100644 --- a/compare.go +++ b/api/compare.go @@ -1,4 +1,4 @@ -package main +package api import ( "bytes" @@ -11,9 +11,13 @@ import ( "time" "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/gorilla/mux" + "github.com/thermokarst/bactdb/helpers" + "github.com/thermokarst/bactdb/payloads" + "github.com/thermokarst/bactdb/types" ) -func handleCompare(w http.ResponseWriter, r *http.Request) *appError { +// HandleCompare is a HTTP handler for comparision. +func HandleCompare(w http.ResponseWriter, r *http.Request) *types.AppError { // types type Comparisions map[string]map[string]string type ComparisionsJSON [][]string @@ -23,7 +27,7 @@ func handleCompare(w http.ResponseWriter, r *http.Request) *appError { if mimeType == "" { mimeType = "json" } - claims := getClaims(r) + claims := helpers.GetClaims(r) var header string var data []byte @@ -33,30 +37,30 @@ func handleCompare(w http.ResponseWriter, r *http.Request) *appError { opt.Del("mimeType") opt.Del("token") opt.Add("Genus", mux.Vars(r)["genus"]) - measurementsEntity, appErr := measService.list(&opt, &claims) + measurementsEntity, appErr := measService.List(&opt, &claims) if appErr != nil { return appErr } - measurementsPayload := (measurementsEntity).(*MeasurementsPayload) + measurementsPayload := (measurementsEntity).(*payloads.Measurements) // Assemble matrix - characteristic_ids := strings.Split(opt.Get("characteristic_ids"), ",") - strain_ids := strings.Split(opt.Get("strain_ids"), ",") + characteristicIDs := strings.Split(opt.Get("characteristic_ids"), ",") + strainIDs := strings.Split(opt.Get("strain_ids"), ",") comparisions := make(Comparisions) - for _, characteristic_id := range characteristic_ids { - characteristic_id_int, _ := strconv.ParseInt(characteristic_id, 10, 0) + for _, characteristicID := range characteristicIDs { + characteristicIDInt, _ := strconv.ParseInt(characteristicID, 10, 0) values := make(map[string]string) - for _, strain_id := range strain_ids { - strain_id_int, _ := strconv.ParseInt(strain_id, 10, 0) + for _, strainID := range strainIDs { + strainIDInt, _ := strconv.ParseInt(strainID, 10, 0) for _, m := range *measurementsPayload.Measurements { - if (m.CharacteristicId == characteristic_id_int) && (m.StrainId == strain_id_int) { - values[strain_id] = m.Value() + if (m.CharacteristicID == characteristicIDInt) && (m.StrainID == strainIDInt) { + values[strainID] = m.Value() } } } - comparisions[characteristic_id] = values + comparisions[characteristicID] = values } // Return, based on mimetype @@ -65,10 +69,10 @@ func handleCompare(w http.ResponseWriter, r *http.Request) *appError { header = "application/json" comparisionsJSON := make(ComparisionsJSON, 0) - for _, characteristic_id := range characteristic_ids { - row := []string{characteristic_id} - for _, strain_id := range strain_ids { - row = append(row, comparisions[characteristic_id][strain_id]) + for _, characteristicID := range characteristicIDs { + row := []string{characteristicID} + for _, strainID := range strainIDs { + row = append(row, comparisions[characteristicID][strainID]) } comparisionsJSON = append(comparisionsJSON, row) } @@ -80,11 +84,11 @@ func handleCompare(w http.ResponseWriter, r *http.Request) *appError { // maps to translate ids strains := make(map[string]string) for _, strain := range *measurementsPayload.Strains { - strains[fmt.Sprintf("%d", strain.Id)] = fmt.Sprintf("%s (%s)", strain.SpeciesName(), strain.StrainName) + strains[fmt.Sprintf("%d", strain.ID)] = fmt.Sprintf("%s (%s)", strain.SpeciesName(), strain.StrainName) } characteristics := make(map[string]string) for _, characteristic := range *measurementsPayload.Characteristics { - characteristics[fmt.Sprintf("%d", characteristic.Id)] = characteristic.CharacteristicName + characteristics[fmt.Sprintf("%d", characteristic.ID)] = characteristic.CharacteristicName } b := &bytes.Buffer{} @@ -92,8 +96,8 @@ func handleCompare(w http.ResponseWriter, r *http.Request) *appError { // Write header row r := []string{"Characteristic"} - for _, strain_id := range strain_ids { - r = append(r, strains[strain_id]) + for _, strainID := range strainIDs { + r = append(r, strains[strainID]) } wr.Write(r) diff --git a/api/entities.go b/api/entities.go new file mode 100644 index 0000000..02657c4 --- /dev/null +++ b/api/entities.go @@ -0,0 +1,34 @@ +package api + +import ( + "net/url" + + "github.com/thermokarst/bactdb/types" +) + +// Getter gets a single entity. +type Getter interface { + Get(int64, string, *types.Claims) (types.Entity, *types.AppError) +} + +// Lister lists entities. +type Lister interface { + List(*url.Values, *types.Claims) (types.Entity, *types.AppError) +} + +// Updater updates entities. +type Updater interface { + Update(int64, *types.Entity, string, *types.Claims) *types.AppError + Unmarshal([]byte) (types.Entity, error) +} + +// Creater creates entities. +type Creater interface { + Create(*types.Entity, string, *types.Claims) *types.AppError + Unmarshal([]byte) (types.Entity, error) +} + +// Deleter deletes entities. +type Deleter interface { + Delete(int64, string, *types.Claims) *types.AppError +} diff --git a/api/helpers.go b/api/helpers.go new file mode 100644 index 0000000..035a4d4 --- /dev/null +++ b/api/helpers.go @@ -0,0 +1,10 @@ +package api + +import "github.com/thermokarst/bactdb/types" + +func newJSONError(err error, status int) *types.AppError { + return &types.AppError{ + Error: types.ErrorJSON{Err: err}, + Status: status, + } +} diff --git a/api/measurements.go b/api/measurements.go new file mode 100644 index 0000000..cfcff88 --- /dev/null +++ b/api/measurements.go @@ -0,0 +1,142 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/url" + + "github.com/thermokarst/bactdb/errors" + "github.com/thermokarst/bactdb/helpers" + "github.com/thermokarst/bactdb/models" + "github.com/thermokarst/bactdb/payloads" + "github.com/thermokarst/bactdb/types" +) + +// MeasurementService provides for CRUD operations. +type MeasurementService struct{} + +// Unmarshal satisfies interface Updater and interface Creater. +func (m MeasurementService) Unmarshal(b []byte) (types.Entity, error) { + var mj payloads.Measurement + err := json.Unmarshal(b, &mj) + return &mj, err +} + +// List lists all measurements. +func (m MeasurementService) List(val *url.Values, claims *types.Claims) (types.Entity, *types.AppError) { + if val == nil { + return nil, newJSONError(errors.ErrMustProvideOptions, http.StatusInternalServerError) + } + var opt helpers.MeasurementListOptions + if err := helpers.SchemaDecoder.Decode(&opt, *val); err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + measurements, err := models.ListMeasurements(opt, claims) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + charOpts, err := models.CharacteristicOptsFromMeasurements(opt) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + characteristics, err := models.ListCharacteristics(*charOpts, claims) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + strainOpts, err := models.StrainOptsFromMeasurements(opt) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + strains, err := models.ListStrains(*strainOpts, claims) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + payload := payloads.Measurements{ + Characteristics: characteristics, + Strains: strains, + Measurements: measurements, + } + + return &payload, nil +} + +// Get retrieves a single measurement. +func (m MeasurementService) Get(id int64, genus string, claims *types.Claims) (types.Entity, *types.AppError) { + measurement, err := models.GetMeasurement(id, genus, claims) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + payload := payloads.Measurement{ + Measurement: measurement, + } + + return &payload, nil +} + +// Update modifies a single measurement. +func (m MeasurementService) Update(id int64, e *types.Entity, genus string, claims *types.Claims) *types.AppError { + payload := (*e).(*payloads.Measurement) + payload.Measurement.UpdatedBy = claims.Sub + payload.Measurement.ID = id + + if payload.Measurement.TextMeasurementType.Valid { + id, err := models.GetTextMeasurementTypeID(payload.Measurement.TextMeasurementType.String) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + payload.Measurement.TextMeasurementTypeID.Int64 = id + payload.Measurement.TextMeasurementTypeID.Valid = true + } + + // TODO: fix this + count, err := models.DBH.Update(payload.Measurement.MeasurementBase) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + if count != 1 { + // TODO: fix this + return newJSONError(errors.ErrStrainNotUpdated, http.StatusBadRequest) + } + + measurement, err := models.GetMeasurement(id, genus, claims) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + + payload.Measurement = measurement + + return nil +} + +// Delete deletes a single measurement. +func (m MeasurementService) Delete(id int64, genus string, claims *types.Claims) *types.AppError { + q := `DELETE FROM measurements WHERE id=$1;` + // TODO: fix this + _, err := models.DBH.Exec(q, id) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + return nil +} + +// Create initializes a new measurement. +func (m MeasurementService) Create(e *types.Entity, genus string, claims *types.Claims) *types.AppError { + payload := (*e).(*payloads.Measurement) + payload.Measurement.CreatedBy = claims.Sub + payload.Measurement.UpdatedBy = claims.Sub + + // TODO: fix this + if err := models.DBH.Insert(payload.Measurement.MeasurementBase); err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + + return nil + +} diff --git a/api/species.go b/api/species.go new file mode 100644 index 0000000..0e5adcc --- /dev/null +++ b/api/species.go @@ -0,0 +1,157 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/url" + + "github.com/thermokarst/bactdb/errors" + "github.com/thermokarst/bactdb/helpers" + "github.com/thermokarst/bactdb/models" + "github.com/thermokarst/bactdb/payloads" + "github.com/thermokarst/bactdb/types" +) + +// SpeciesService provides for CRUD operations +type SpeciesService struct{} + +// Unmarshal satisfies interface Updater and interface Creater +func (s SpeciesService) Unmarshal(b []byte) (types.Entity, error) { + var sj payloads.Species + err := json.Unmarshal(b, &sj) + return &sj, err +} + +// List lists species +func (s SpeciesService) List(val *url.Values, claims *types.Claims) (types.Entity, *types.AppError) { + if val == nil { + return nil, newJSONError(errors.ErrMustProvideOptions, http.StatusInternalServerError) + } + var opt helpers.ListOptions + if err := helpers.SchemaDecoder.Decode(&opt, *val); err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + species, err := models.ListSpecies(opt, claims) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + strainsOpt, err := models.StrainOptsFromSpecies(opt) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + strains, err := models.ListStrains(*strainsOpt, claims) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + payload := payloads.ManySpecies{ + Species: species, + Strains: strains, + Meta: &models.SpeciesMeta{ + CanAdd: helpers.CanAdd(claims), + }, + } + + return &payload, nil +} + +// Get retrieves a single species +func (s SpeciesService) Get(id int64, genus string, claims *types.Claims) (types.Entity, *types.AppError) { + species, err := models.GetSpecies(id, genus, claims) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + strains, err := models.StrainsFromSpeciesID(id, genus, claims) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + payload := payloads.Species{ + Species: species, + Strains: strains, + Meta: &models.SpeciesMeta{ + CanAdd: helpers.CanAdd(claims), + }, + } + + return &payload, nil +} + +// Update modifies an existing species +func (s SpeciesService) Update(id int64, e *types.Entity, genus string, claims *types.Claims) *types.AppError { + payload := (*e).(*payloads.Species) + payload.Species.UpdatedBy = claims.Sub + payload.Species.ID = id + + genusID, err := models.GenusIDFromName(genus) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + payload.Species.SpeciesBase.GenusID = genusID + + // TODO: fix this + count, err := models.DBH.Update(payload.Species.SpeciesBase) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + if count != 1 { + // TODO: fix this + return newJSONError(errors.ErrSpeciesNotUpdated, http.StatusBadRequest) + } + + // Reload to send back down the wire + species, err := models.GetSpecies(id, genus, claims) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + + strains, err := models.StrainsFromSpeciesID(id, genus, claims) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + + payload.Species = species + payload.Strains = strains + payload.Meta = &models.SpeciesMeta{ + CanAdd: helpers.CanAdd(claims), + } + + return nil +} + +// Create initializes a new species +func (s SpeciesService) Create(e *types.Entity, genus string, claims *types.Claims) *types.AppError { + payload := (*e).(*payloads.Species) + payload.Species.CreatedBy = claims.Sub + payload.Species.UpdatedBy = claims.Sub + + genusID, err := models.GenusIDFromName(genus) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + payload.Species.SpeciesBase.GenusID = genusID + + // TODO: fix this + err = models.DBH.Insert(payload.Species.SpeciesBase) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + + // Reload to send back down the wire + species, err := models.GetSpecies(payload.Species.ID, genus, claims) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + + // Note, no strains when new species + + payload.Species = species + payload.Meta = &models.SpeciesMeta{ + CanAdd: helpers.CanAdd(claims), + } + return nil +} diff --git a/api/strains.go b/api/strains.go new file mode 100644 index 0000000..15b8b6f --- /dev/null +++ b/api/strains.go @@ -0,0 +1,219 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/url" + + "github.com/thermokarst/bactdb/errors" + "github.com/thermokarst/bactdb/helpers" + "github.com/thermokarst/bactdb/models" + "github.com/thermokarst/bactdb/payloads" + "github.com/thermokarst/bactdb/types" +) + +// StrainService provides for CRUD operations +type StrainService struct{} + +// Unmarshal satisfies interface Updater and interface Creater +func (s StrainService) Unmarshal(b []byte) (types.Entity, error) { + var sj payloads.Strain + err := json.Unmarshal(b, &sj) + return &sj, err +} + +// List lists all strains +func (s StrainService) List(val *url.Values, claims *types.Claims) (types.Entity, *types.AppError) { + if val == nil { + return nil, newJSONError(errors.ErrMustProvideOptions, http.StatusInternalServerError) + } + var opt helpers.ListOptions + if err := helpers.SchemaDecoder.Decode(&opt, *val); err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + strains, err := models.ListStrains(opt, claims) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + speciesOpt, err := models.SpeciesOptsFromStrains(opt) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + species, err := models.ListSpecies(*speciesOpt, claims) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + characteristicsOpt, err := models.CharacteristicsOptsFromStrains(opt) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + characteristics, err := models.ListCharacteristics(*characteristicsOpt, claims) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + characteristicIDs := []int64{} + for _, c := range *characteristics { + characteristicIDs = append(characteristicIDs, c.ID) + } + + strainIDs := []int64{} + for _, s := range *strains { + strainIDs = append(strainIDs, s.ID) + } + + measurementOpt := helpers.MeasurementListOptions{ + ListOptions: helpers.ListOptions{ + Genus: opt.Genus, + }, + Strains: strainIDs, + Characteristics: characteristicIDs, + } + + measurements, err := models.ListMeasurements(measurementOpt, claims) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + payload := payloads.Strains{ + Strains: strains, + Species: species, + Measurements: measurements, + Characteristics: characteristics, + Meta: &models.StrainMeta{ + CanAdd: helpers.CanAdd(claims), + }, + } + + return &payload, nil +} + +// Get retrieves a single strain +func (s StrainService) Get(id int64, genus string, claims *types.Claims) (types.Entity, *types.AppError) { + strain, err := models.GetStrain(id, genus, claims) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + species, err := models.GetSpecies(strain.SpeciesID, genus, claims) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + opt := helpers.ListOptions{Genus: genus, IDs: []int64{id}} + characteristicsOpt, err := models.CharacteristicsOptsFromStrains(opt) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + characteristics, err := models.ListCharacteristics(*characteristicsOpt, claims) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + characteristicIDs := []int64{} + for _, c := range *characteristics { + characteristicIDs = append(characteristicIDs, c.ID) + } + + measurementOpt := helpers.MeasurementListOptions{ + ListOptions: helpers.ListOptions{ + Genus: genus, + }, + Strains: []int64{id}, + Characteristics: characteristicIDs, + } + + measurements, err := models.ListMeasurements(measurementOpt, claims) + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + var manySpecies models.ManySpecies = []*models.Species{species} + + payload := payloads.Strain{ + Strain: strain, + Species: &manySpecies, + Characteristics: characteristics, + Measurements: measurements, + Meta: &models.StrainMeta{ + CanAdd: helpers.CanAdd(claims), + }, + } + + return &payload, nil +} + +// Update modifies an existing strain +func (s StrainService) Update(id int64, e *types.Entity, genus string, claims *types.Claims) *types.AppError { + payload := (*e).(*payloads.Strain) + payload.Strain.UpdatedBy = claims.Sub + payload.Strain.ID = id + + // TODO: fix this + count, err := models.DBH.Update(payload.Strain.StrainBase) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + if count != 1 { + // TODO: fix this + return newJSONError(errors.ErrStrainNotUpdated, http.StatusBadRequest) + } + + strain, err := models.GetStrain(id, genus, claims) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + + species, err := models.GetSpecies(strain.SpeciesID, genus, claims) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + + var manySpecies models.ManySpecies = []*models.Species{species} + + payload.Strain = strain + payload.Species = &manySpecies + payload.Meta = &models.StrainMeta{ + CanAdd: helpers.CanAdd(claims), + } + + return nil +} + +// Create initializes a new strain +func (s StrainService) Create(e *types.Entity, genus string, claims *types.Claims) *types.AppError { + payload := (*e).(*payloads.Strain) + payload.Strain.CreatedBy = claims.Sub + payload.Strain.UpdatedBy = claims.Sub + + // TODO: fix this + if err := models.DBH.Insert(payload.Strain.StrainBase); err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + + strain, err := models.GetStrain(payload.Strain.ID, genus, claims) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + + species, err := models.GetSpecies(strain.SpeciesID, genus, claims) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + + var manySpecies models.ManySpecies = []*models.Species{species} + + payload.Strain = strain + payload.Species = &manySpecies + payload.Meta = &models.StrainMeta{ + CanAdd: helpers.CanAdd(claims), + } + + return nil +} diff --git a/api/users.go b/api/users.go new file mode 100644 index 0000000..8abb266 --- /dev/null +++ b/api/users.go @@ -0,0 +1,266 @@ +package api + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "net/url" + + "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/gorilla/mux" + "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/lib/pq" + "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/mailgun/mailgun-go" + "github.com/thermokarst/bactdb/Godeps/_workspace/src/golang.org/x/crypto/bcrypt" + "github.com/thermokarst/bactdb/auth" + "github.com/thermokarst/bactdb/errors" + "github.com/thermokarst/bactdb/helpers" + "github.com/thermokarst/bactdb/models" + "github.com/thermokarst/bactdb/payloads" + "github.com/thermokarst/bactdb/types" +) + +var ( + // MgAccts is a map of Mailgun accounts. + MgAccts = make(map[string]mailgun.Mailgun) +) + +// UserService provides for CRUD operations. +type UserService struct{} + +// Unmarshal satisfies interface Updater and interface Creater. +func (u UserService) Unmarshal(b []byte) (types.Entity, error) { + var uj payloads.User + err := json.Unmarshal(b, &uj) + return &uj, err +} + +// List lists all users. +func (u UserService) List(val *url.Values, claims *types.Claims) (types.Entity, *types.AppError) { + if val == nil { + return nil, newJSONError(errors.ErrMustProvideOptions, http.StatusInternalServerError) + } + var opt helpers.ListOptions + if err := helpers.SchemaDecoder.Decode(&opt, *val); err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + // TODO: fix this + users := make(models.Users, 0) + sql := `SELECT id, email, 'password' AS password, name, role, + created_at, updated_at, deleted_at + FROM users + WHERE verified IS TRUE + AND deleted_at IS NULL;` + if err := models.DBH.Select(&users, sql); err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + payload := payloads.Users{ + Users: &users, + Meta: &models.UserMeta{ + CanAdd: claims.Role == "A", + }, + } + return &payload, nil +} + +// Get retrieves a single user. +func (u UserService) Get(id int64, dummy string, claims *types.Claims) (types.Entity, *types.AppError) { + user, err := models.DbGetUserByID(id) + user.Password = "" + if err != nil { + return nil, newJSONError(err, http.StatusInternalServerError) + } + + user.CanEdit = claims.Role == "A" || id == claims.Sub + + payload := payloads.User{ + User: user, + Meta: &models.UserMeta{ + CanAdd: claims.Role == "A", + }, + } + return &payload, nil +} + +// Update modifies an existing user. +func (u UserService) Update(id int64, e *types.Entity, dummy string, claims *types.Claims) *types.AppError { + user := (*e).(*payloads.User).User + + originalUser, err := models.DbGetUserByID(id) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + + user.ID = id + user.Password = originalUser.Password + user.Verified = originalUser.Verified + user.UpdatedAt = helpers.CurrentTime() + + if err := user.Validate(); err != nil { + return &types.AppError{Error: err, Status: helpers.StatusUnprocessableEntity} + } + + // TODO: fix this + count, err := models.DBH.Update(user) + user.Password = "" + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + if count != 1 { + return newJSONError(errors.ErrUserNotUpdated, http.StatusInternalServerError) + } + + return nil +} + +// Create initializes a new user. +func (u UserService) Create(e *types.Entity, dummy string, claims *types.Claims) *types.AppError { + user := (*e).(*payloads.User).User + if err := user.Validate(); err != nil { + return &types.AppError{Error: err, Status: helpers.StatusUnprocessableEntity} + } + ct := helpers.CurrentTime() + user.CreatedAt = ct + user.UpdatedAt = ct + hash, err := bcrypt.GenerateFromPassword([]byte(user.Password), 12) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + user.Password = string(hash) + user.Role = "R" + user.Verified = false + + // TODO: fix this + if err := models.DBH.Insert(user.UserBase); err != nil { + if err, ok := err.(*pq.Error); ok { + if err.Code == "23505" { + return newJSONError(errors.ErrEmailAddressTaken, http.StatusInternalServerError) + } + } + return newJSONError(err, http.StatusInternalServerError) + } + + user.Password = "password" // don't want to send the hashed PW back to the client + + q := `INSERT INTO verification (user_id, nonce, referer, created_at) VALUES ($1, $2, $3, $4);` + // TODO: move helpers.GenerateNonce + nonce, err := helpers.GenerateNonce() + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + // TODO: fix this + _, err = models.DBH.Exec(q, user.ID, nonce, claims.Ref, ct) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + + // Send out confirmation email + // TODO: clean this up + mg, ok := MgAccts[claims.Ref] + if ok { + sender := fmt.Sprintf("%s Admin ", mg.Domain(), mg.Domain()) + recipient := fmt.Sprintf("%s <%s>", user.Name, user.Email) + subject := fmt.Sprintf("New Account Confirmation - %s", mg.Domain()) + message := fmt.Sprintf("You are receiving this message because this email "+ + "address was used to sign up for an account at %s. Please visit this "+ + "URL to complete the sign up process: %s/users/new/verify/%s. If you "+ + "did not request an account, please disregard this message.", + mg.Domain(), claims.Ref, nonce) + m := mailgun.NewMessage(sender, subject, message, recipient) + _, _, err := mg.Send(m) + if err != nil { + log.Printf("%+v\n", err) + return newJSONError(err, http.StatusInternalServerError) + } + } + + return nil +} + +// HandleUserVerify is a HTTP handler for verifiying a user. +func HandleUserVerify(w http.ResponseWriter, r *http.Request) *types.AppError { + // TODO: clean this up + nonce := mux.Vars(r)["Nonce"] + q := `SELECT user_id, referer FROM verification WHERE nonce=$1;` + + var ver struct { + UserID int64 + Referer string + } + if err := models.DBH.SelectOne(&ver, q, nonce); err != nil { + log.Print(err) + return newJSONError(err, http.StatusInternalServerError) + } + + if ver.UserID == 0 { + return newJSONError(errors.ErrUserNotFound, http.StatusInternalServerError) + } + + var user models.User + if err := models.DBH.Get(&user, ver.UserID); err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + + user.UpdatedAt = helpers.CurrentTime() + user.Verified = true + + count, err := models.DBH.Update(&user) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + if count != 1 { + return newJSONError(errors.ErrUserNotUpdated, http.StatusInternalServerError) + } + + q = `DELETE FROM verification WHERE user_id=$1;` + _, err = models.DBH.Exec(q, user.ID) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + fmt.Fprintln(w, `{"msg":"All set! Please log in."}`) + return nil +} + +// HandleUserLockout is a HTTP handler for unlocking a user's account. +func HandleUserLockout(w http.ResponseWriter, r *http.Request) *types.AppError { + email := r.FormValue("email") + if email == "" { + return newJSONError(errors.ErrUserMissingEmail, http.StatusInternalServerError) + } + token, err := auth.Middleware.CreateToken(email) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + origin := r.Header.Get("Origin") + hostURL, err := url.Parse(origin) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + hostURL.Path += "/users/lockoutauthenticate" + params := url.Values{} + params.Add("token", token) + hostURL.RawQuery = params.Encode() + + // Send out email + // TODO: clean this up + mg, ok := MgAccts[origin] + if ok { + sender := fmt.Sprintf("%s Admin ", mg.Domain(), mg.Domain()) + recipient := fmt.Sprintf("%s", email) + subject := fmt.Sprintf("Password Reset Request - %s", mg.Domain()) + message := fmt.Sprintf("You are receiving this message because this email "+ + "address was used in an account lockout request at %s. Please visit "+ + "this URL to complete the process: %s. If you did not request help "+ + "with a lockout, please disregard this message.", + mg.Domain(), hostURL.String()) + m := mailgun.NewMessage(sender, subject, message, recipient) + _, _, err := mg.Send(m) + if err != nil { + log.Printf("%+v\n", err) + return newJSONError(err, http.StatusInternalServerError) + } + } + + fmt.Fprintln(w, `{}`) + return nil +} diff --git a/auth/auth.go b/auth/auth.go new file mode 100644 index 0000000..a4e777b --- /dev/null +++ b/auth/auth.go @@ -0,0 +1,46 @@ +package auth + +import ( + "os" + "time" + + "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/thermokarst/jwt" + "github.com/thermokarst/bactdb/models" +) + +var ( + // Middleware is for JWT + Middleware *jwt.Middleware + // Config handles JWT middleware configuration + Config = &jwt.Config{ + Secret: os.Getenv("SECRET"), + Auth: models.DbAuthenticate, + Claims: claimsFunc, + } +) + +func claimsFunc(email string) (map[string]interface{}, error) { + // TODO: use helper + currentTime := time.Now() + user, err := models.DbGetUserByEmail(email) + if err != nil { + return nil, err + } + return map[string]interface{}{ + "name": user.Name, + "iss": "bactdb", + "sub": user.ID, + "role": user.Role, + "iat": currentTime.Unix(), + "exp": currentTime.Add(time.Minute * 60).Unix(), + "ref": "", + }, nil +} + +func init() { + var err error + Middleware, err = jwt.New(Config) + if err != nil { + panic(err) + } +} diff --git a/characteristics.go b/characteristics.go deleted file mode 100644 index 0535fd7..0000000 --- a/characteristics.go +++ /dev/null @@ -1,435 +0,0 @@ -package main - -import ( - "database/sql" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/url" - - "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/jmoiron/modl" -) - -var ( - ErrCharacteristicNotFound = errors.New("Characteristic not found") - ErrCharacteristicNotUpdated = errors.New("Characteristic not updated") -) - -func init() { - DB.AddTableWithName(CharacteristicBase{}, "characteristics").SetKeys(true, "Id") -} - -func (c *CharacteristicBase) PreInsert(e modl.SqlExecutor) error { - ct := currentTime() - c.CreatedAt = ct - c.UpdatedAt = ct - return nil -} - -func (c *CharacteristicBase) PreUpdate(e modl.SqlExecutor) error { - c.UpdatedAt = currentTime() - return nil -} - -type CharacteristicService struct{} - -type CharacteristicBase struct { - Id int64 `json:"id,omitempty"` - CharacteristicName string `db:"characteristic_name" json:"characteristicName"` - CharacteristicTypeId int64 `db:"characteristic_type_id" json:"-"` - SortOrder NullInt64 `db:"sort_order" json:"sortOrder"` - CreatedAt NullTime `db:"created_at" json:"createdAt"` - UpdatedAt NullTime `db:"updated_at" json:"updatedAt"` - DeletedAt NullTime `db:"deleted_at" json:"deletedAt"` - CreatedBy int64 `db:"created_by" json:"createdBy"` - UpdatedBy int64 `db:"updated_by" json:"updatedBy"` - DeletedBy NullInt64 `db:"deleted_by" json:"deletedBy"` -} - -type Characteristic struct { - *CharacteristicBase - Measurements NullSliceInt64 `db:"measurements" json:"measurements"` - Strains NullSliceInt64 `db:"strains" json:"strains"` - CharacteristicType string `db:"characteristic_type_name" json:"characteristicTypeName"` - CanEdit bool `db:"-" json:"canEdit"` -} - -type Characteristics []*Characteristic - -type CharacteristicMeta struct { - CanAdd bool `json:"canAdd"` -} - -type CharacteristicPayload struct { - Characteristic *Characteristic `json:"characteristic"` - Measurements *Measurements `json:"measurements"` - Strains *Strains `json:"strains"` - Species *ManySpecies `json:"species"` - Meta *CharacteristicMeta `json:"meta"` -} - -type CharacteristicsPayload struct { - Characteristics *Characteristics `json:"characteristics"` - Measurements *Measurements `json:"measurements"` - Strains *Strains `json:"strains"` - Species *ManySpecies `json:"species"` - Meta *CharacteristicMeta `json:"meta"` -} - -func (c *CharacteristicPayload) marshal() ([]byte, error) { - return json.Marshal(c) -} - -func (c *CharacteristicsPayload) marshal() ([]byte, error) { - return json.Marshal(c) -} - -func (c CharacteristicService) unmarshal(b []byte) (entity, error) { - var cj CharacteristicPayload - err := json.Unmarshal(b, &cj) - return &cj, err -} - -func (c CharacteristicService) list(val *url.Values, claims *Claims) (entity, *appError) { - if val == nil { - return nil, ErrMustProvideOptionsJSON - } - var opt ListOptions - if err := schemaDecoder.Decode(&opt, *val); err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - characteristics, err := listCharacteristics(opt, claims) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - strains_opt, err := strainOptsFromCharacteristics(opt) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - strains, err := listStrains(*strains_opt, claims) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - species_opt, err := speciesOptsFromStrains(*strains_opt) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - species, err := listSpecies(*species_opt, claims) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - measurements_opt, err := measurementOptsFromCharacteristics(opt) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - measurements, err := listMeasurements(*measurements_opt, claims) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - payload := CharacteristicsPayload{ - Characteristics: characteristics, - Measurements: measurements, - Strains: strains, - Species: species, - Meta: &CharacteristicMeta{ - CanAdd: canAdd(claims), - }, - } - - return &payload, nil -} - -func (c CharacteristicService) get(id int64, genus string, claims *Claims) (entity, *appError) { - characteristic, err := getCharacteristic(id, genus, claims) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - strains, strain_opts, err := strainsFromCharacteristicId(id, genus, claims) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - species_opt, err := speciesOptsFromStrains(*strain_opts) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - species, err := listSpecies(*species_opt, claims) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - measurements, _, err := measurementsFromCharacteristicId(id, genus, claims) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - payload := CharacteristicPayload{ - Characteristic: characteristic, - Measurements: measurements, - Strains: strains, - Species: species, - } - - return &payload, nil -} - -func (c CharacteristicService) update(id int64, e *entity, genus string, claims *Claims) *appError { - payload := (*e).(*CharacteristicPayload) - payload.Characteristic.UpdatedBy = claims.Sub - payload.Characteristic.Id = id - - // First, handle Characteristic Type - id, err := insertOrGetCharacteristicType(payload.Characteristic.CharacteristicType, claims) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - - payload.Characteristic.CanEdit = canEdit(claims, payload.Characteristic.CreatedBy) - - payload.Characteristic.CharacteristicTypeId = id - count, err := DBH.Update(payload.Characteristic.CharacteristicBase) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - if count != 1 { - return newJSONError(ErrCharacteristicNotUpdated, http.StatusBadRequest) - } - - strains, strain_opts, err := strainsFromCharacteristicId(id, genus, claims) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - - species_opt, err := speciesOptsFromStrains(*strain_opts) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - - species, err := listSpecies(*species_opt, claims) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - - payload.Strains = strains - // TODO: tack on measurements - payload.Measurements = nil - payload.Species = species - - return nil -} - -func (c CharacteristicService) create(e *entity, genus string, claims *Claims) *appError { - payload := (*e).(*CharacteristicPayload) - payload.Characteristic.CreatedBy = claims.Sub - payload.Characteristic.UpdatedBy = claims.Sub - - id, err := insertOrGetCharacteristicType(payload.Characteristic.CharacteristicType, claims) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - payload.Characteristic.CharacteristicTypeId = id - - err = DBH.Insert(payload.Characteristic.CharacteristicBase) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - - characteristic, err := getCharacteristic(payload.Characteristic.Id, genus, claims) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - - payload.Characteristic = characteristic - payload.Meta = &CharacteristicMeta{ - CanAdd: canAdd(claims), - } - return nil -} - -func listCharacteristics(opt ListOptions, claims *Claims) (*Characteristics, error) { - var vals []interface{} - - q := `SELECT c.*, ct.characteristic_type_name, - array_agg(DISTINCT st.id) AS strains, array_agg(DISTINCT m.id) AS measurements - FROM strains st - INNER JOIN species sp ON sp.id=st.species_id - INNER JOIN genera g ON g.id=sp.genus_id AND LOWER(g.genus_name)=LOWER($1) - INNER JOIN measurements m ON m.strain_id=st.id - RIGHT OUTER JOIN characteristics c ON c.id=m.characteristic_id - INNER JOIN characteristic_types ct ON ct.id=c.characteristic_type_id` - vals = append(vals, opt.Genus) - - if len(opt.Ids) != 0 { - var counter int64 = 2 - w := valsIn("c.id", opt.Ids, &vals, &counter) - - q += fmt.Sprintf(" WHERE %s", w) - } - - q += ` GROUP BY c.id, ct.characteristic_type_name - ORDER BY ct.characteristic_type_name, c.sort_order ASC;` - - var characteristics Characteristics - err := DBH.Select(&characteristics, q, vals...) - if err != nil { - return nil, err - } - - for _, c := range characteristics { - c.CanEdit = canEdit(claims, c.CreatedBy) - } - - return &characteristics, nil -} - -func strainOptsFromCharacteristics(opt ListOptions) (*ListOptions, error) { - relatedStrainIds := make([]int64, 0) - baseQ := `SELECT DISTINCT m.strain_id - FROM measurements m - INNER JOIN strains st ON st.id=m.strain_id - INNER JOIN species sp ON sp.id=st.species_id - INNER JOIN genera g ON g.id=sp.genus_id AND LOWER(g.genus_name)=LOWER($1)` - if opt.Ids == nil { - q := fmt.Sprintf("%s;", baseQ) - if err := DBH.Select(&relatedStrainIds, q, opt.Genus); err != nil { - return nil, err - } - } else { - var vals []interface{} - var count int64 = 2 - vals = append(vals, opt.Genus) - q := fmt.Sprintf("%s WHERE %s ", baseQ, valsIn("m.characteristic_id", opt.Ids, &vals, &count)) - - if err := DBH.Select(&relatedStrainIds, q, vals...); err != nil { - return nil, err - } - } - - return &ListOptions{Genus: opt.Genus, Ids: relatedStrainIds}, nil -} - -func measurementOptsFromCharacteristics(opt ListOptions) (*MeasurementListOptions, error) { - relatedMeasurementIds := make([]int64, 0) - baseQ := `SELECT m.id - FROM measurements m - INNER JOIN strains st ON st.id=m.strain_id - INNER JOIN species sp ON sp.id=st.species_id - INNER JOIN genera g ON g.id=sp.genus_id AND LOWER(g.genus_name)=LOWER($1)` - - if opt.Ids == nil { - q := fmt.Sprintf("%s;", baseQ) - if err := DBH.Select(&relatedMeasurementIds, q, opt.Genus); err != nil { - return nil, err - } - } else { - var vals []interface{} - var count int64 = 2 - vals = append(vals, opt.Genus) - q := fmt.Sprintf("%s WHERE %s;", baseQ, valsIn("characteristic_id", opt.Ids, &vals, &count)) - - if err := DBH.Select(&relatedMeasurementIds, q, vals...); err != nil { - return nil, err - } - } - - return &MeasurementListOptions{ListOptions: ListOptions{Genus: opt.Genus, Ids: relatedMeasurementIds}, Strains: nil, Characteristics: nil}, nil -} - -func strainsFromCharacteristicId(id int64, genus string, claims *Claims) (*Strains, *ListOptions, error) { - opt := ListOptions{ - Genus: genus, - Ids: []int64{id}, - } - - strains_opt, err := strainOptsFromCharacteristics(opt) - if err != nil { - return nil, nil, err - } - - strains, err := listStrains(*strains_opt, claims) - if err != nil { - return nil, nil, err - } - - return strains, strains_opt, nil -} - -func measurementsFromCharacteristicId(id int64, genus string, claims *Claims) (*Measurements, *MeasurementListOptions, error) { - opt := ListOptions{ - Genus: genus, - Ids: []int64{id}, - } - - measurement_opt, err := measurementOptsFromCharacteristics(opt) - if err != nil { - return nil, nil, err - } - - measurements, err := listMeasurements(*measurement_opt, claims) - if err != nil { - return nil, nil, err - } - - return measurements, measurement_opt, nil -} - -func getCharacteristic(id int64, genus string, claims *Claims) (*Characteristic, error) { - var characteristic Characteristic - q := `SELECT c.*, ct.characteristic_type_name, - array_agg(DISTINCT st.id) AS strains, array_agg(DISTINCT m.id) AS measurements - FROM strains st - INNER JOIN species sp ON sp.id=st.species_id - INNER JOIN genera g ON g.id=sp.genus_id AND LOWER(g.genus_name)=LOWER($1) - INNER JOIN measurements m ON m.strain_id=st.id - RIGHT OUTER JOIN characteristics c ON c.id=m.characteristic_id - INNER JOIN characteristic_types ct ON ct.id=c.characteristic_type_id - WHERE c.id=$2 - GROUP BY c.id, ct.characteristic_type_name;` - if err := DBH.SelectOne(&characteristic, q, genus, id); err != nil { - if err == sql.ErrNoRows { - return nil, ErrCharacteristicNotFound - } - return nil, err - } - - characteristic.CanEdit = canEdit(claims, characteristic.CreatedBy) - - return &characteristic, nil -} - -func insertOrGetCharacteristicType(val string, claims *Claims) (int64, error) { - var id int64 - q := `SELECT id FROM characteristic_types WHERE characteristic_type_name=$1;` - if err := DBH.SelectOne(&id, q, val); err != nil { - if err == sql.ErrNoRows { - i := `INSERT INTO characteristic_types - (characteristic_type_name, created_at, updated_at, created_by, updated_by) - VALUES ($1, $2, $3, $4, $5) RETURNING id;` - ct := currentTime() - var result sql.Result - var insertErr error - stmt, err := DB.Db.Prepare(i) - if result, insertErr = stmt.Exec(val, ct, ct, claims.Sub, claims.Sub); insertErr != nil { - return 0, insertErr - } - id, err = result.LastInsertId() - if err != nil { - return 0, err - } - } else { - return 0, err - } - } - return id, nil -} diff --git a/entities.go b/entities.go deleted file mode 100644 index 6165044..0000000 --- a/entities.go +++ /dev/null @@ -1,28 +0,0 @@ -package main - -import "net/url" - -type entity interface { - marshal() ([]byte, error) -} - -type getter interface { - get(int64, string, *Claims) (entity, *appError) -} - -type lister interface { - list(*url.Values, *Claims) (entity, *appError) -} - -type updater interface { - update(int64, *entity, string, *Claims) *appError - unmarshal([]byte) (entity, error) -} - -type creater interface { - create(*entity, string, *Claims) *appError - unmarshal([]byte) (entity, error) -} -type deleter interface { - delete(int64, string, *Claims) *appError -} diff --git a/errors/auth.go b/errors/auth.go new file mode 100644 index 0000000..2602f55 --- /dev/null +++ b/errors/auth.go @@ -0,0 +1,8 @@ +package errors + +import "errors" + +var ( + // ErrExpiredToken when expired token. + ErrExpiredToken = errors.New("this token has expired") +) diff --git a/errors/characteristics.go b/errors/characteristics.go new file mode 100644 index 0000000..f625887 --- /dev/null +++ b/errors/characteristics.go @@ -0,0 +1,10 @@ +package errors + +import "errors" + +var ( + // ErrCharacteristicNotFound when not found. + ErrCharacteristicNotFound = errors.New("Characteristic not found") + // ErrCharacteristicNotUpdated when not updated. + ErrCharacteristicNotUpdated = errors.New("Characteristic not updated") +) diff --git a/errors/helpers.go b/errors/helpers.go new file mode 100644 index 0000000..1a4a599 --- /dev/null +++ b/errors/helpers.go @@ -0,0 +1,8 @@ +package errors + +import "errors" + +var ( + // ErrMustProvideOptions when missing options. + ErrMustProvideOptions = errors.New("Must provide necessary options") +) diff --git a/errors/measurements.go b/errors/measurements.go new file mode 100644 index 0000000..1a65f4a --- /dev/null +++ b/errors/measurements.go @@ -0,0 +1,8 @@ +package errors + +import "errors" + +var ( + // ErrMeasurementNotFound when not found. + ErrMeasurementNotFound = errors.New("Measurement not found") +) diff --git a/errors/species.go b/errors/species.go new file mode 100644 index 0000000..0500fd7 --- /dev/null +++ b/errors/species.go @@ -0,0 +1,10 @@ +package errors + +import "errors" + +var ( + // ErrSpeciesNotFound when not found. + ErrSpeciesNotFound = errors.New("Species not found") + // ErrSpeciesNotUpdated when not updated. + ErrSpeciesNotUpdated = errors.New("Species not updated") +) diff --git a/errors/strains.go b/errors/strains.go new file mode 100644 index 0000000..fd0ec6f --- /dev/null +++ b/errors/strains.go @@ -0,0 +1,10 @@ +package errors + +import "errors" + +var ( + // ErrStrainNotFound when not found. + ErrStrainNotFound = errors.New("Strain not found") + // ErrStrainNotUpdated when not updated. + ErrStrainNotUpdated = errors.New("Strain not updated") +) diff --git a/errors/types.go b/errors/types.go new file mode 100644 index 0000000..fe76b97 --- /dev/null +++ b/errors/types.go @@ -0,0 +1,8 @@ +package errors + +import "errors" + +var ( + // ErrSourceNotByteSlice when not a byte-slice. + ErrSourceNotByteSlice = errors.New("Scan source was not []byte") +) diff --git a/errors/users.go b/errors/users.go new file mode 100644 index 0000000..eaa7cdc --- /dev/null +++ b/errors/users.go @@ -0,0 +1,16 @@ +package errors + +import "errors" + +var ( + // ErrUserNotFound when not found. + ErrUserNotFound = errors.New("No user found") + // ErrUserNotUpdated when not updated. + ErrUserNotUpdated = errors.New("Count 0") + // ErrUserMissingEmail when missing email. + ErrUserMissingEmail = errors.New("Missing email") + // ErrInvalidEmailOrPassword when invalid login credentials. + ErrInvalidEmailOrPassword = errors.New("Invalid email or password") + // ErrEmailAddressTaken when email already registered. + ErrEmailAddressTaken = errors.New("Email address is already registered") +) diff --git a/handlers.go b/handlers/handlers.go similarity index 60% rename from handlers.go rename to handlers/handlers.go index 8f0033a..6380c67 100644 --- a/handlers.go +++ b/handlers/handlers.go @@ -1,8 +1,7 @@ -package main +package handlers import ( "encoding/json" - "errors" "fmt" "io/ioutil" "net/http" @@ -16,85 +15,49 @@ import ( "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/gorilla/mux" "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/nytimes/gziphandler" "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/thermokarst/jwt" + "github.com/thermokarst/bactdb/api" + "github.com/thermokarst/bactdb/auth" + "github.com/thermokarst/bactdb/errors" + "github.com/thermokarst/bactdb/helpers" + "github.com/thermokarst/bactdb/models" + "github.com/thermokarst/bactdb/types" ) -var ( - config *jwt.Config - j *jwt.Middleware -) - -type Claims struct { - Name string - Iss string - Sub int64 - Role string - Iat int64 - Exp int64 - Ref string -} - func verifyClaims(claims []byte, r *http.Request) error { + // TODO: use helper currentTime := time.Now() - var c Claims + var c types.Claims err := json.Unmarshal(claims, &c) if err != nil { return err } if currentTime.After(time.Unix(c.Exp, 0)) { - return errors.New("this token has expired") + return errors.ErrExpiredToken } context.Set(r, "claims", c) return nil } +// Handler is the root HTTP handler for bactdb. func Handler() http.Handler { - claimsFunc := func(email string) (map[string]interface{}, error) { - currentTime := time.Now() - user, err := dbGetUserByEmail(email) - if err != nil { - return nil, err - } - return map[string]interface{}{ - "name": user.Name, - "iss": "bactdb", - "sub": user.Id, - "role": user.Role, - "iat": currentTime.Unix(), - "exp": currentTime.Add(time.Minute * 60).Unix(), - "ref": "", - }, nil - } - - config = &jwt.Config{ - Secret: os.Getenv("SECRET"), - Auth: dbAuthenticate, - Claims: claimsFunc, - } - - var err error - j, err = jwt.New(config) - if err != nil { - panic(err) - } - m := mux.NewRouter() - userService := UserService{} - strainService := StrainService{} - speciesService := SpeciesService{} - characteristicService := CharacteristicService{} - measurementService := MeasurementService{} + userService := api.UserService{} + strainService := api.StrainService{} + speciesService := api.SpeciesService{} + characteristicService := api.CharacteristicService{} + measurementService := api.MeasurementService{} - m.Handle("/authenticate", tokenHandler(j.Authenticate())).Methods("POST") - m.Handle("/refresh", j.Secure(errorHandler(tokenRefresh(j)), verifyClaims)).Methods("POST") + m.Handle("/authenticate", tokenHandler(auth.Middleware.Authenticate())).Methods("POST") + m.Handle("/refresh", auth.Middleware.Secure(errorHandler(tokenRefresh(auth.Middleware)), verifyClaims)).Methods("POST") // Everything past here is lumped under a genus s := m.PathPrefix("/{genus}").Subrouter() s.Handle("/users", errorHandler(handleCreater(userService))).Methods("POST") - s.Handle("/users/verify/{Nonce}", errorHandler(handleUserVerify)).Methods("GET") - s.Handle("/users/lockout", errorHandler(handleUserLockout)).Methods("POST") + s.Handle("/users/verify/{Nonce}", errorHandler(api.HandleUserVerify)).Methods("GET") + s.Handle("/users/lockout", errorHandler(api.HandleUserLockout)).Methods("POST") - s.Handle("/compare", j.Secure(errorHandler(handleCompare), verifyClaims)).Methods("GET") + s.Handle("/compare", auth.Middleware.Secure(errorHandler(api.HandleCompare), verifyClaims)).Methods("GET") type r struct { f errorHandler @@ -105,49 +68,49 @@ func Handler() http.Handler { // Everything past this point requires a valid token routes := []r{ r{handleLister(userService), "GET", "/users"}, - r{handleGetter(userService), "GET", "/users/{Id:.+}"}, - r{handleUpdater(userService), "PUT", "/users/{Id:.+}"}, + r{handleGetter(userService), "GET", "/users/{ID:.+}"}, + r{handleUpdater(userService), "PUT", "/users/{ID:.+}"}, r{handleLister(speciesService), "GET", "/species"}, r{handleCreater(speciesService), "POST", "/species"}, - r{handleGetter(speciesService), "GET", "/species/{Id:.+}"}, - r{handleUpdater(speciesService), "PUT", "/species/{Id:.+}"}, + r{handleGetter(speciesService), "GET", "/species/{ID:.+}"}, + r{handleUpdater(speciesService), "PUT", "/species/{ID:.+}"}, r{handleLister(strainService), "GET", "/strains"}, r{handleCreater(strainService), "POST", "/strains"}, - r{handleGetter(strainService), "GET", "/strains/{Id:.+}"}, - r{handleUpdater(strainService), "PUT", "/strains/{Id:.+}"}, + r{handleGetter(strainService), "GET", "/strains/{ID:.+}"}, + r{handleUpdater(strainService), "PUT", "/strains/{ID:.+}"}, r{handleLister(characteristicService), "GET", "/characteristics"}, r{handleCreater(characteristicService), "POST", "/characteristics"}, - r{handleGetter(characteristicService), "GET", "/characteristics/{Id:.+}"}, - r{handleUpdater(characteristicService), "PUT", "/characteristics/{Id:.+}"}, + r{handleGetter(characteristicService), "GET", "/characteristics/{ID:.+}"}, + r{handleUpdater(characteristicService), "PUT", "/characteristics/{ID:.+}"}, r{handleLister(measurementService), "GET", "/measurements"}, r{handleCreater(measurementService), "POST", "/measurements"}, - r{handleGetter(measurementService), "GET", "/measurements/{Id:.+}"}, - r{handleUpdater(measurementService), "PUT", "/measurements/{Id:.+}"}, - r{handleDeleter(measurementService), "DELETE", "/measurements/{Id:.+}"}, + r{handleGetter(measurementService), "GET", "/measurements/{ID:.+}"}, + r{handleUpdater(measurementService), "PUT", "/measurements/{ID:.+}"}, + r{handleDeleter(measurementService), "DELETE", "/measurements/{ID:.+}"}, } for _, route := range routes { - s.Handle(route.p, j.Secure(errorHandler(route.f), verifyClaims)).Methods(route.m) + s.Handle(route.p, auth.Middleware.Secure(errorHandler(route.f), verifyClaims)).Methods(route.m) } return jsonHandler(gziphandler.GzipHandler(corsHandler(m))) } -func handleGetter(g getter) errorHandler { - return func(w http.ResponseWriter, r *http.Request) *appError { - id, err := strconv.ParseInt(mux.Vars(r)["Id"], 10, 0) +func handleGetter(g api.Getter) errorHandler { + return func(w http.ResponseWriter, r *http.Request) *types.AppError { + id, err := strconv.ParseInt(mux.Vars(r)["ID"], 10, 0) if err != nil { return newJSONError(err, http.StatusInternalServerError) } - claims := getClaims(r) + claims := helpers.GetClaims(r) - e, appErr := g.get(id, mux.Vars(r)["genus"], &claims) + e, appErr := g.Get(id, mux.Vars(r)["genus"], &claims) if appErr != nil { return appErr } - data, err := e.marshal() + data, err := e.Marshal() if err != nil { return newJSONError(err, http.StatusInternalServerError) } @@ -156,18 +119,18 @@ func handleGetter(g getter) errorHandler { } } -func handleLister(l lister) errorHandler { - return func(w http.ResponseWriter, r *http.Request) *appError { +func handleLister(l api.Lister) errorHandler { + return func(w http.ResponseWriter, r *http.Request) *types.AppError { opt := r.URL.Query() opt.Add("Genus", mux.Vars(r)["genus"]) - claims := getClaims(r) + claims := helpers.GetClaims(r) - es, appErr := l.list(&opt, &claims) + es, appErr := l.List(&opt, &claims) if appErr != nil { return appErr } - data, err := es.marshal() + data, err := es.Marshal() if err != nil { return newJSONError(err, http.StatusInternalServerError) } @@ -176,9 +139,9 @@ func handleLister(l lister) errorHandler { } } -func handleUpdater(u updater) errorHandler { - return func(w http.ResponseWriter, r *http.Request) *appError { - id, err := strconv.ParseInt(mux.Vars(r)["Id"], 10, 0) +func handleUpdater(u api.Updater) errorHandler { + return func(w http.ResponseWriter, r *http.Request) *types.AppError { + id, err := strconv.ParseInt(mux.Vars(r)["ID"], 10, 0) if err != nil { return newJSONError(err, http.StatusInternalServerError) } @@ -188,19 +151,19 @@ func handleUpdater(u updater) errorHandler { return newJSONError(err, http.StatusInternalServerError) } - e, err := u.unmarshal(bodyBytes) + e, err := u.Unmarshal(bodyBytes) if err != nil { return newJSONError(err, http.StatusInternalServerError) } - claims := getClaims(r) + claims := helpers.GetClaims(r) - appErr := u.update(id, &e, mux.Vars(r)["genus"], &claims) + appErr := u.Update(id, &e, mux.Vars(r)["genus"], &claims) if appErr != nil { return appErr } - data, err := e.marshal() + data, err := e.Marshal() if err != nil { return newJSONError(err, http.StatusInternalServerError) } @@ -209,26 +172,26 @@ func handleUpdater(u updater) errorHandler { } } -func handleCreater(c creater) errorHandler { - return func(w http.ResponseWriter, r *http.Request) *appError { +func handleCreater(c api.Creater) errorHandler { + return func(w http.ResponseWriter, r *http.Request) *types.AppError { bodyBytes, err := ioutil.ReadAll(r.Body) if err != nil { return newJSONError(err, http.StatusInternalServerError) } - e, err := c.unmarshal(bodyBytes) + e, err := c.Unmarshal(bodyBytes) if err != nil { return newJSONError(err, http.StatusInternalServerError) } - claims := getClaims(r) + claims := helpers.GetClaims(r) - appErr := c.create(&e, mux.Vars(r)["genus"], &claims) + appErr := c.Create(&e, mux.Vars(r)["genus"], &claims) if appErr != nil { return appErr } - data, err := e.marshal() + data, err := e.Marshal() if err != nil { return newJSONError(err, http.StatusInternalServerError) } @@ -237,16 +200,16 @@ func handleCreater(c creater) errorHandler { } } -func handleDeleter(d deleter) errorHandler { - return func(w http.ResponseWriter, r *http.Request) *appError { - id, err := strconv.ParseInt(mux.Vars(r)["Id"], 10, 0) +func handleDeleter(d api.Deleter) errorHandler { + return func(w http.ResponseWriter, r *http.Request) *types.AppError { + id, err := strconv.ParseInt(mux.Vars(r)["ID"], 10, 0) if err != nil { return newJSONError(err, http.StatusInternalServerError) } - claims := getClaims(r) + claims := helpers.GetClaims(r) - appErr := d.delete(id, mux.Vars(r)["genus"], &claims) + appErr := d.Delete(id, mux.Vars(r)["genus"], &claims) if appErr != nil { return appErr } @@ -320,7 +283,7 @@ func jsonHandler(h http.Handler) http.Handler { return http.HandlerFunc(j) } -type errorHandler func(http.ResponseWriter, *http.Request) *appError +type errorHandler func(http.ResponseWriter, *http.Request) *types.AppError func (fn errorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if err := fn(w, r); err != nil { @@ -330,13 +293,14 @@ func (fn errorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } func tokenRefresh(j *jwt.Middleware) errorHandler { - t := func(w http.ResponseWriter, r *http.Request) *appError { - claims := getClaims(r) - user, err := dbGetUserById(claims.Sub) + t := func(w http.ResponseWriter, r *http.Request) *types.AppError { + claims := helpers.GetClaims(r) + user, err := models.DbGetUserByID(claims.Sub) if err != nil { return newJSONError(err, http.StatusInternalServerError) } - token, err := j.CreateToken(user.Email) + user.Password = "" + token, err := auth.Middleware.CreateToken(user.Email) if err != nil { return newJSONError(err, http.StatusInternalServerError) } diff --git a/handlers/helpers.go b/handlers/helpers.go new file mode 100644 index 0000000..c43f52b --- /dev/null +++ b/handlers/helpers.go @@ -0,0 +1,10 @@ +package handlers + +import "github.com/thermokarst/bactdb/types" + +func newJSONError(err error, status int) *types.AppError { + return &types.AppError{ + Error: types.ErrorJSON{Err: err}, + Status: status, + } +} diff --git a/helpers.go b/helpers.go deleted file mode 100644 index 57f50c0..0000000 --- a/helpers.go +++ /dev/null @@ -1,103 +0,0 @@ -package main - -import ( - "crypto/rand" - "encoding/base64" - "errors" - "fmt" - "net/http" - "time" - - "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/gorilla/context" - "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/lib/pq" -) - -var ( - ErrMustProvideOptions = errors.New("Must provide necessary options") - ErrMustProvideOptionsJSON = newJSONError(ErrMustProvideOptions, http.StatusBadRequest) - StatusUnprocessableEntity = 422 - MustProvideAValue = "Must provide a value" -) - -// ListOptions specifies general pagination options for fetching a list of results -type ListOptions struct { - PerPage int64 `url:",omitempty" json:",omitempty"` - Page int64 `url:",omitempty" json:",omitempty"` - Ids []int64 `url:",omitempty" json:",omitempty" schema:"ids[]"` - Genus string -} - -func (o ListOptions) PageOrDefault() int64 { - if o.Page <= 0 { - return 1 - } - return o.Page -} - -func (o ListOptions) Offset() int64 { - return (o.PageOrDefault() - 1) * o.PerPageOrDefault() -} - -func (o ListOptions) PerPageOrDefault() int64 { - if o.PerPage <= 0 { - return DefaultPerPage - } - return o.PerPage -} - -// DefaultPerPage is the default number of items to return in a paginated result set -const DefaultPerPage = 10 - -func valsIn(attribute string, values []int64, vals *[]interface{}, counter *int64) string { - if len(values) == 1 { - return fmt.Sprintf("%v=%v", attribute, values[0]) - } - - m := fmt.Sprintf("%v IN (", attribute) - for _, id := range values { - m = m + fmt.Sprintf("$%v,", *counter) - *vals = append(*vals, id) - *counter++ - } - m = m[:len(m)-1] + ")" - return m -} - -func currentTime() NullTime { - return NullTime{ - pq.NullTime{ - Time: time.Now(), - Valid: true, - }, - } -} - -func generateNonce() (string, error) { - b := make([]byte, 32) - _, err := rand.Read(b) - if err != nil { - return "", err - } - return base64.URLEncoding.EncodeToString(b), nil -} - -func getClaims(r *http.Request) Claims { - con := context.Get(r, "claims") - var claims Claims - if con != nil { - claims = con.(Claims) - } - origin := r.Header.Get("Origin") - if origin != "" { - claims.Ref = origin - } - return claims -} - -func canAdd(claims *Claims) bool { - return claims.Role == "A" || claims.Role == "W" -} - -func canEdit(claims *Claims, author int64) bool { - return claims.Sub == author || claims.Role == "A" -} diff --git a/helpers/helpers.go b/helpers/helpers.go new file mode 100644 index 0000000..65f56d2 --- /dev/null +++ b/helpers/helpers.go @@ -0,0 +1,99 @@ +package helpers + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "net/http" + "time" + + "github.com/gorilla/schema" + "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/gorilla/context" + "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/lib/pq" + "github.com/thermokarst/bactdb/types" +) + +var ( + // StatusUnprocessableEntity is the HTTP status when Unprocessable Entity. + StatusUnprocessableEntity = 422 + // MustProvideAValue when value required. + MustProvideAValue = "Must provide a value" + // SchemaDecoder for decoding schemas. + SchemaDecoder = schema.NewDecoder() +) + +// ListOptions specifies general pagination options for fetching a list of results +type ListOptions struct { + PerPage int64 `url:",omitempty" json:",omitempty"` + Page int64 `url:",omitempty" json:",omitempty"` + IDs []int64 `url:",omitempty" json:",omitempty" schema:"ids[]"` + Genus string +} + +// MeasurementListOptions is an extension of ListOptions. +type MeasurementListOptions struct { + ListOptions + Strains []int64 `schema:"strain_ids"` + Characteristics []int64 `schema:"characteristic_ids"` +} + +// ValsIn emits X IN (A, B, C) SQL statements +func ValsIn(attribute string, values []int64, vals *[]interface{}, counter *int64) string { + if len(values) == 1 { + return fmt.Sprintf("%v=%v", attribute, values[0]) + } + + m := fmt.Sprintf("%v IN (", attribute) + for _, id := range values { + m = m + fmt.Sprintf("$%v,", *counter) + *vals = append(*vals, id) + *counter++ + } + m = m[:len(m)-1] + ")" + return m +} + +// CurrentTime returns current time +func CurrentTime() types.NullTime { + return types.NullTime{ + pq.NullTime{ + Time: time.Now(), + Valid: true, + }, + } +} + +// GenerateNonce generates a nonce +func GenerateNonce() (string, error) { + // TODO: move this + b := make([]byte, 32) + _, err := rand.Read(b) + if err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(b), nil +} + +// GetClaims gets request claims from Authorization header +func GetClaims(r *http.Request) types.Claims { + con := context.Get(r, "claims") + var claims types.Claims + if con != nil { + claims = con.(types.Claims) + } + origin := r.Header.Get("Origin") + if origin != "" { + claims.Ref = origin + } + return claims +} + +// CanAdd is an authorization helper for adding new entities +func CanAdd(claims *types.Claims) bool { + return claims.Role == "A" || claims.Role == "W" +} + +// CanEdit is an authorization helper for editing entities +func CanEdit(claims *types.Claims, author int64) bool { + return claims.Sub == author || claims.Role == "A" +} diff --git a/main.go b/main.go index 1004d89..3920bda 100644 --- a/main.go +++ b/main.go @@ -10,21 +10,15 @@ import ( "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/DavidHuie/gomigrate" "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/codegangsta/cli" - "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/gorilla/schema" - "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/jmoiron/modl" "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/jmoiron/sqlx" "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/lib/pq" "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/mailgun/mailgun-go" + "github.com/thermokarst/bactdb/api" + "github.com/thermokarst/bactdb/handlers" + "github.com/thermokarst/bactdb/models" ) -var ( - DB = &modl.DbMap{Dialect: modl.PostgresDialect{}} - DBH modl.SqlExecutor = DB - schemaDecoder = schema.NewDecoder() - mgAccts = make(map[string]mailgun.Mailgun) -) - -func main() { +func init() { var connectOnce sync.Once connectOnce.Do(func() { var err error @@ -37,14 +31,16 @@ func main() { } else { connection += " sslmode=disable" } - DB.Dbx, err = sqlx.Open("postgres", connection) + models.DB.Dbx, err = sqlx.Open("postgres", connection) if err != nil { log.Fatal("Error connecting to PostgreSQL database (using PG* environment variables): ", err) } - DB.TraceOn("[modl]", log.New(os.Stdout, "bactdb:", log.Lmicroseconds)) - DB.Db = DB.Dbx.DB + models.DB.TraceOn("[modl]", log.New(os.Stdout, "bactdb:", log.Lmicroseconds)) + models.DB.Db = models.DB.Dbx.DB }) +} +func main() { app := cli.NewApp() app.Name = "bactdb" app.Usage = "a database for bacteria" @@ -100,7 +96,7 @@ func cmdServe(c *cli.Context) { log.Printf("Mailgun: %+v", accounts) for _, a := range accounts { - mgAccts[a.Ref] = mailgun.NewMailgun(a.Domain, a.Private, a.Public) + api.MgAccts[a.Ref] = mailgun.NewMailgun(a.Domain, a.Private, a.Public) } addr := os.Getenv("PORT") @@ -110,7 +106,7 @@ func cmdServe(c *cli.Context) { httpAddr := fmt.Sprintf(":%v", addr) m := http.NewServeMux() - m.Handle("/api/", http.StripPrefix("/api", Handler())) + m.Handle("/api/", http.StripPrefix("/api", handlers.Handler())) log.Print("Listening on ", httpAddr) err = http.ListenAndServe(httpAddr, m) @@ -121,17 +117,18 @@ func cmdServe(c *cli.Context) { func cmdMigrateDb(c *cli.Context) { migrationsPath := c.String("migration_path") - migrator, err := gomigrate.NewMigrator(DB.Dbx.DB, gomigrate.Postgres{}, migrationsPath) + migrator, err := gomigrate.NewMigrator(models.DB.Dbx.DB, gomigrate.Postgres{}, migrationsPath) if err != nil { log.Fatal("Error initializing migrations: ", err) } - users := make(Users, 0) + users := make(models.Users, 0) if c.Bool("drop") { // Back up users table - if err := DBH.Select(&users, `SELECT * FROM users;`); err != nil { - log.Fatal("Couldn't back up identity tables: ", err) + // TODO: look into this + if err := models.DBH.Select(&users, `SELECT * FROM users;`); err != nil { + log.Printf("Couldn't back up identity tables: %+v", err) } log.Printf("%+v Users", len(users)) @@ -152,7 +149,8 @@ func cmdMigrateDb(c *cli.Context) { if len(users) > 0 { // varargs don't seem to work here, loop instead for _, user := range users { - if err := DBH.Insert(user); err != nil { + // TODO: look into this + if err := models.DBH.Insert(user.UserBase); err != nil { log.Fatal("Couldn't restore user: ", err) } } diff --git a/measurements.go b/measurements.go deleted file mode 100644 index 5783fb4..0000000 --- a/measurements.go +++ /dev/null @@ -1,375 +0,0 @@ -package main - -import ( - "database/sql" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/url" - - "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/jmoiron/modl" -) - -var ( - ErrMeasurementNotFound = errors.New("Measurement not found") - ErrMeasurementNotFoundJSON = newJSONError(ErrMeasurementNotFound, http.StatusNotFound) -) - -func init() { - DB.AddTableWithName(MeasurementBase{}, "measurements").SetKeys(true, "Id") -} - -func (m *MeasurementBase) PreInsert(e modl.SqlExecutor) error { - ct := currentTime() - m.CreatedAt = ct - m.UpdatedAt = ct - return nil -} - -func (m *MeasurementBase) PreUpdate(e modl.SqlExecutor) error { - m.UpdatedAt = currentTime() - return nil -} - -type MeasurementService struct{} - -// There are three types of supported measurements: fixed-text, free-text, -// & numerical. The table has a constraint that will allow at most one -// for a particular combination of strain & characteristic. -// MeasurementBase is what the DB expects to see for inserts/updates -type MeasurementBase struct { - Id int64 `json:"id,omitempty"` - StrainId int64 `db:"strain_id" json:"strain"` - CharacteristicId int64 `db:"characteristic_id" json:"characteristic"` - TextMeasurementTypeId NullInt64 `db:"text_measurement_type_id" json:"-"` - TxtValue NullString `db:"txt_value" json:"-"` - NumValue NullFloat64 `db:"num_value" json:"-"` - ConfidenceInterval NullFloat64 `db:"confidence_interval" json:"confidenceInterval"` - UnitTypeId NullInt64 `db:"unit_type_id" json:"-"` - Notes NullString `db:"notes" json:"notes"` - TestMethodId NullInt64 `db:"test_method_id" json:"-"` - CreatedAt NullTime `db:"created_at" json:"createdAt"` - UpdatedAt NullTime `db:"updated_at" json:"updatedAt"` - CreatedBy int64 `db:"created_by" json:"createdBy"` - UpdatedBy int64 `db:"updated_by" json:"updatedBy"` -} - -type Measurement struct { - *MeasurementBase - TextMeasurementType NullString `db:"text_measurement_type_name" json:"-"` - UnitType NullString `db:"unit_type_name" json:"unitType"` - TestMethod NullString `db:"test_method_name" json:"testMethod"` - CanEdit bool `db:"-" json:"canEdit"` -} - -type FakeMeasurement Measurement - -func (m *Measurement) MarshalJSON() ([]byte, error) { - fm := FakeMeasurement(*m) - return json.Marshal(struct { - *FakeMeasurement - Value string `json:"value"` - }{ - FakeMeasurement: &fm, - Value: m.Value(), - }) -} - -func (m *Measurement) UnmarshalJSON(b []byte) error { - var measurement struct { - FakeMeasurement - Value interface{} `json:"value"` - } - if err := json.Unmarshal(b, &measurement); err != nil { - return err - } - - switch v := measurement.Value.(type) { - case string: - // Test if actually a lookup - id, err := getTextMeasurementTypeId(v) - if err != nil { - if err == sql.ErrNoRows { - measurement.TxtValue = NullString{sql.NullString{String: v, Valid: true}} - } else { - return err - } - } else { - measurement.TextMeasurementTypeId = NullInt64{sql.NullInt64{Int64: id, Valid: true}} - } - case int64: - measurement.NumValue = NullFloat64{sql.NullFloat64{Float64: float64(v), Valid: true}} - case float64: - measurement.NumValue = NullFloat64{sql.NullFloat64{Float64: v, Valid: true}} - } - - *m = Measurement(measurement.FakeMeasurement) - - return nil -} - -func (m *Measurement) Value() string { - if m.TextMeasurementType.Valid { - return m.TextMeasurementType.String - } - if m.TxtValue.Valid { - return m.TxtValue.String - } - if m.NumValue.Valid { - return fmt.Sprintf("%f", m.NumValue.Float64) - } - return "" -} - -type Measurements []*Measurement - -type MeasurementMeta struct { - CanAdd bool `json:"canAdd"` -} - -type MeasurementPayload struct { - Measurement *Measurement `json:"measurement"` -} - -type MeasurementsPayload struct { - Strains *Strains `json:"strains"` - Characteristics *Characteristics `json:"characteristics"` - Measurements *Measurements `json:"measurements"` -} - -func (m *MeasurementPayload) marshal() ([]byte, error) { - return json.Marshal(m) -} - -func (m *MeasurementsPayload) marshal() ([]byte, error) { - return json.Marshal(m) -} - -func (s MeasurementService) unmarshal(b []byte) (entity, error) { - var mj MeasurementPayload - err := json.Unmarshal(b, &mj) - return &mj, err -} - -type MeasurementListOptions struct { - ListOptions - Strains []int64 `schema:"strain_ids"` - Characteristics []int64 `schema:"characteristic_ids"` -} - -func (m MeasurementService) list(val *url.Values, claims *Claims) (entity, *appError) { - if val == nil { - return nil, ErrMustProvideOptionsJSON - } - var opt MeasurementListOptions - if err := schemaDecoder.Decode(&opt, *val); err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - measurements, err := listMeasurements(opt, claims) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - char_opts, err := characteristicOptsFromMeasurements(opt) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - characteristics, err := listCharacteristics(*char_opts, claims) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - strain_opts, err := strainOptsFromMeasurements(opt) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - strains, err := listStrains(*strain_opts, claims) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - payload := MeasurementsPayload{ - Characteristics: characteristics, - Strains: strains, - Measurements: measurements, - } - - return &payload, nil -} - -func (m MeasurementService) get(id int64, genus string, claims *Claims) (entity, *appError) { - measurement, err := getMeasurement(id, genus, claims) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - payload := MeasurementPayload{ - Measurement: measurement, - } - - return &payload, nil -} - -func (s MeasurementService) update(id int64, e *entity, genus string, claims *Claims) *appError { - payload := (*e).(*MeasurementPayload) - payload.Measurement.UpdatedBy = claims.Sub - payload.Measurement.Id = id - - if payload.Measurement.TextMeasurementType.Valid { - id, err := getTextMeasurementTypeId(payload.Measurement.TextMeasurementType.String) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - payload.Measurement.TextMeasurementTypeId.Int64 = id - payload.Measurement.TextMeasurementTypeId.Valid = true - } - - count, err := DBH.Update(payload.Measurement.MeasurementBase) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - if count != 1 { - return newJSONError(ErrStrainNotUpdated, http.StatusBadRequest) - } - - measurement, err := getMeasurement(id, genus, claims) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - - payload.Measurement = measurement - - return nil -} - -func (m MeasurementService) delete(id int64, genus string, claims *Claims) *appError { - q := `DELETE FROM measurements WHERE id=$1;` - _, err := DBH.Exec(q, id) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - return nil -} - -func (m MeasurementService) create(e *entity, genus string, claims *Claims) *appError { - payload := (*e).(*MeasurementPayload) - payload.Measurement.CreatedBy = claims.Sub - payload.Measurement.UpdatedBy = claims.Sub - - if err := DBH.Insert(payload.Measurement.MeasurementBase); err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - - return nil - -} - -func listMeasurements(opt MeasurementListOptions, claims *Claims) (*Measurements, error) { - var vals []interface{} - - q := `SELECT m.*, t.text_measurement_name AS text_measurement_type_name, - u.symbol AS unit_type_name, te.name AS test_method_name - FROM measurements m - INNER JOIN strains st ON st.id=m.strain_id - INNER JOIN species sp ON sp.id=st.species_id - INNER JOIN genera g ON g.id=sp.genus_id AND LOWER(g.genus_name)=$1 - LEFT OUTER JOIN characteristics c ON c.id=m.characteristic_id - LEFT OUTER JOIN text_measurement_types t ON t.id=m.text_measurement_type_id - LEFT OUTER JOIN unit_types u ON u.id=m.unit_type_id - LEFT OUTER JOIN test_methods te ON te.id=m.test_method_id` - vals = append(vals, opt.Genus) - - strainIds := len(opt.Strains) != 0 - charIds := len(opt.Characteristics) != 0 - ids := len(opt.Ids) != 0 - - if strainIds || charIds || ids { - var paramsCounter int64 = 2 - q += "\nWHERE (" - - // Filter by strains - if strainIds { - q += valsIn("st.id", opt.Strains, &vals, ¶msCounter) - } - - if strainIds && (charIds || ids) { - q += " AND " - } - - // Filter by characteristics - if charIds { - q += valsIn("c.id", opt.Characteristics, &vals, ¶msCounter) - } - - if charIds && ids { - q += " AND " - } - - // Get specific records - if ids { - q += valsIn("m.id", opt.Ids, &vals, ¶msCounter) - } - q += ")" - } - q += ";" - - measurements := make(Measurements, 0) - err := DBH.Select(&measurements, q, vals...) - if err != nil { - return nil, err - } - - for _, m := range measurements { - m.CanEdit = canEdit(claims, m.CreatedBy) - } - - return &measurements, nil -} - -func getMeasurement(id int64, genus string, claims *Claims) (*Measurement, error) { - var measurement Measurement - - q := `SELECT m.*, t.text_measurement_name AS text_measurement_type_name, - u.symbol AS unit_type_name, te.name AS test_method_name - FROM measurements m - INNER JOIN strains st ON st.id=m.strain_id - INNER JOIN species sp ON sp.id=st.species_id - INNER JOIN genera g ON g.id=sp.genus_id AND LOWER(g.genus_name)=LOWER($1) - LEFT OUTER JOIN characteristics c ON c.id=m.characteristic_id - LEFT OUTER JOIN text_measurement_types t ON t.id=m.text_measurement_type_id - LEFT OUTER JOIN unit_types u ON u.id=m.unit_type_id - LEFT OUTER JOIN test_methods te ON te.id=m.test_method_id - WHERE m.id=$2;` - if err := DBH.SelectOne(&measurement, q, genus, id); err != nil { - if err == sql.ErrNoRows { - return nil, ErrMeasurementNotFound - } - return nil, err - } - - measurement.CanEdit = canEdit(claims, measurement.CreatedBy) - - return &measurement, nil -} - -func characteristicOptsFromMeasurements(opt MeasurementListOptions) (*ListOptions, error) { - return &ListOptions{Genus: opt.Genus, Ids: opt.Characteristics}, nil -} - -func strainOptsFromMeasurements(opt MeasurementListOptions) (*ListOptions, error) { - return &ListOptions{Genus: opt.Genus, Ids: opt.Strains}, nil -} - -func getTextMeasurementTypeId(val string) (int64, error) { - var id int64 - q := `SELECT id FROM text_measurement_types WHERE text_measurement_name=$1;` - - if err := DBH.SelectOne(&id, q, val); err != nil { - return 0, err - } - return id, nil -} diff --git a/models/characteristics.go b/models/characteristics.go new file mode 100644 index 0000000..cb56ecc --- /dev/null +++ b/models/characteristics.go @@ -0,0 +1,250 @@ +package models + +import ( + "database/sql" + "fmt" + + "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/jmoiron/modl" + "github.com/thermokarst/bactdb/errors" + "github.com/thermokarst/bactdb/helpers" + "github.com/thermokarst/bactdb/types" +) + +func init() { + DB.AddTableWithName(CharacteristicBase{}, "characteristics").SetKeys(true, "ID") +} + +// PreInsert is a modl hook +func (c *CharacteristicBase) PreInsert(e modl.SqlExecutor) error { + ct := helpers.CurrentTime() + c.CreatedAt = ct + c.UpdatedAt = ct + return nil +} + +// PreUpdate is a modl hook +func (c *CharacteristicBase) PreUpdate(e modl.SqlExecutor) error { + c.UpdatedAt = helpers.CurrentTime() + return nil +} + +// CharacteristicBase is what the DB expects for write operations +type CharacteristicBase struct { + ID int64 `json:"id,omitempty"` + CharacteristicName string `db:"characteristic_name" json:"characteristicName"` + CharacteristicTypeID int64 `db:"characteristic_type_id" json:"-"` + SortOrder types.NullInt64 `db:"sort_order" json:"sortOrder"` + CreatedAt types.NullTime `db:"created_at" json:"createdAt"` + UpdatedAt types.NullTime `db:"updated_at" json:"updatedAt"` + DeletedAt types.NullTime `db:"deleted_at" json:"deletedAt"` + CreatedBy int64 `db:"created_by" json:"createdBy"` + UpdatedBy int64 `db:"updated_by" json:"updatedBy"` + DeletedBy types.NullInt64 `db:"deleted_by" json:"deletedBy"` +} + +// Characteristic is what the DB expects for read operations, and is what the API +// expects to return to the requester. +type Characteristic struct { + *CharacteristicBase + Measurements types.NullSliceInt64 `db:"measurements" json:"measurements"` + Strains types.NullSliceInt64 `db:"strains" json:"strains"` + CharacteristicType string `db:"characteristic_type_name" json:"characteristicTypeName"` + CanEdit bool `db:"-" json:"canEdit"` +} + +// Characteristics are multiple characteristic entities +type Characteristics []*Characteristic + +// CharacteristicMeta stashes some metadata related to the entity +type CharacteristicMeta struct { + CanAdd bool `json:"canAdd"` +} + +// ListCharacteristics returns all characteristics +func ListCharacteristics(opt helpers.ListOptions, claims *types.Claims) (*Characteristics, error) { + var vals []interface{} + + q := `SELECT c.*, ct.characteristic_type_name, + array_agg(DISTINCT st.id) AS strains, array_agg(DISTINCT m.id) AS measurements + FROM strains st + INNER JOIN species sp ON sp.id=st.species_id + INNER JOIN genera g ON g.id=sp.genus_id AND LOWER(g.genus_name)=LOWER($1) + INNER JOIN measurements m ON m.strain_id=st.id + RIGHT OUTER JOIN characteristics c ON c.id=m.characteristic_id + INNER JOIN characteristic_types ct ON ct.id=c.characteristic_type_id` + vals = append(vals, opt.Genus) + + if len(opt.IDs) != 0 { + var counter int64 = 2 + w := helpers.ValsIn("c.id", opt.IDs, &vals, &counter) + + q += fmt.Sprintf(" WHERE %s", w) + } + + q += ` GROUP BY c.id, ct.characteristic_type_name + ORDER BY ct.characteristic_type_name, c.sort_order ASC;` + + var characteristics Characteristics + err := DBH.Select(&characteristics, q, vals...) + if err != nil { + return nil, err + } + + for _, c := range characteristics { + c.CanEdit = helpers.CanEdit(claims, c.CreatedBy) + } + + return &characteristics, nil +} + +// StrainOptsFromCharacteristics returns the options for finding all related strains +// for a set of characteristics. +func StrainOptsFromCharacteristics(opt helpers.ListOptions) (*helpers.ListOptions, error) { + var relatedStrainIDs []int64 + baseQ := `SELECT DISTINCT m.strain_id + FROM measurements m + INNER JOIN strains st ON st.id=m.strain_id + INNER JOIN species sp ON sp.id=st.species_id + INNER JOIN genera g ON g.id=sp.genus_id AND LOWER(g.genus_name)=LOWER($1)` + if opt.IDs == nil { + q := fmt.Sprintf("%s;", baseQ) + if err := DBH.Select(&relatedStrainIDs, q, opt.Genus); err != nil { + return nil, err + } + } else { + var vals []interface{} + var count int64 = 2 + vals = append(vals, opt.Genus) + q := fmt.Sprintf("%s WHERE %s ", baseQ, helpers.ValsIn("m.characteristic_id", opt.IDs, &vals, &count)) + + if err := DBH.Select(&relatedStrainIDs, q, vals...); err != nil { + return nil, err + } + } + + return &helpers.ListOptions{Genus: opt.Genus, IDs: relatedStrainIDs}, nil +} + +// MeasurementOptsFromCharacteristics returns the options for finding all related +// measurements for a set of characteristics. +func MeasurementOptsFromCharacteristics(opt helpers.ListOptions) (*helpers.MeasurementListOptions, error) { + var relatedMeasurementIDs []int64 + baseQ := `SELECT m.id + FROM measurements m + INNER JOIN strains st ON st.id=m.strain_id + INNER JOIN species sp ON sp.id=st.species_id + INNER JOIN genera g ON g.id=sp.genus_id AND LOWER(g.genus_name)=LOWER($1)` + + if opt.IDs == nil { + q := fmt.Sprintf("%s;", baseQ) + if err := DBH.Select(&relatedMeasurementIDs, q, opt.Genus); err != nil { + return nil, err + } + } else { + var vals []interface{} + var count int64 = 2 + vals = append(vals, opt.Genus) + q := fmt.Sprintf("%s WHERE %s;", baseQ, helpers.ValsIn("characteristic_id", opt.IDs, &vals, &count)) + + if err := DBH.Select(&relatedMeasurementIDs, q, vals...); err != nil { + return nil, err + } + } + + return &helpers.MeasurementListOptions{ListOptions: helpers.ListOptions{Genus: opt.Genus, IDs: relatedMeasurementIDs}, Strains: nil, Characteristics: nil}, nil +} + +// StrainsFromCharacteristicID returns a set of strains (as well as the options for +// finding those strains) for a particular characteristic. +func StrainsFromCharacteristicID(id int64, genus string, claims *types.Claims) (*Strains, *helpers.ListOptions, error) { + opt := helpers.ListOptions{ + Genus: genus, + IDs: []int64{id}, + } + + strainsOpt, err := StrainOptsFromCharacteristics(opt) + if err != nil { + return nil, nil, err + } + + strains, err := ListStrains(*strainsOpt, claims) + if err != nil { + return nil, nil, err + } + + return strains, strainsOpt, nil +} + +// MeasurementsFromCharacteristicID returns a set of measurements (as well as the +// options for finding those measurements) for a particular characteristic. +func MeasurementsFromCharacteristicID(id int64, genus string, claims *types.Claims) (*Measurements, *helpers.MeasurementListOptions, error) { + opt := helpers.ListOptions{ + Genus: genus, + IDs: []int64{id}, + } + + measurementOpt, err := MeasurementOptsFromCharacteristics(opt) + if err != nil { + return nil, nil, err + } + + measurements, err := ListMeasurements(*measurementOpt, claims) + if err != nil { + return nil, nil, err + } + + return measurements, measurementOpt, nil +} + +// GetCharacteristic returns a particular characteristic. +func GetCharacteristic(id int64, genus string, claims *types.Claims) (*Characteristic, error) { + var characteristic Characteristic + q := `SELECT c.*, ct.characteristic_type_name, + array_agg(DISTINCT st.id) AS strains, array_agg(DISTINCT m.id) AS measurements + FROM strains st + INNER JOIN species sp ON sp.id=st.species_id + INNER JOIN genera g ON g.id=sp.genus_id AND LOWER(g.genus_name)=LOWER($1) + INNER JOIN measurements m ON m.strain_id=st.id + RIGHT OUTER JOIN characteristics c ON c.id=m.characteristic_id + INNER JOIN characteristic_types ct ON ct.id=c.characteristic_type_id + WHERE c.id=$2 + GROUP BY c.id, ct.characteristic_type_name;` + if err := DBH.SelectOne(&characteristic, q, genus, id); err != nil { + if err == sql.ErrNoRows { + return nil, errors.ErrCharacteristicNotFound + } + return nil, err + } + + characteristic.CanEdit = helpers.CanEdit(claims, characteristic.CreatedBy) + + return &characteristic, nil +} + +// InsertOrGetCharacteristicType performs an UPSERT operation on the database +// for a characteristic type. +func InsertOrGetCharacteristicType(val string, claims *types.Claims) (int64, error) { + var id int64 + q := `SELECT id FROM characteristic_types WHERE characteristic_type_name=$1;` + if err := DBH.SelectOne(&id, q, val); err != nil { + if err == sql.ErrNoRows { + i := `INSERT INTO characteristic_types + (characteristic_type_name, created_at, updated_at, created_by, updated_by) + VALUES ($1, $2, $3, $4, $5) RETURNING id;` + ct := helpers.CurrentTime() + var result sql.Result + var insertErr error + stmt, err := DB.Db.Prepare(i) + if result, insertErr = stmt.Exec(val, ct, ct, claims.Sub, claims.Sub); insertErr != nil { + return 0, insertErr + } + id, err = result.LastInsertId() + if err != nil { + return 0, err + } + } else { + return 0, err + } + } + return id, nil +} diff --git a/models/database.go b/models/database.go new file mode 100644 index 0000000..fd4febf --- /dev/null +++ b/models/database.go @@ -0,0 +1,10 @@ +package models + +import "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/jmoiron/modl" + +var ( + // DB is a sqlx/modl database map. + DB = &modl.DbMap{Dialect: modl.PostgresDialect{}} + // DBH is a global database handler. + DBH modl.SqlExecutor = DB +) diff --git a/models/measurements.go b/models/measurements.go new file mode 100644 index 0000000..3ccc945 --- /dev/null +++ b/models/measurements.go @@ -0,0 +1,245 @@ +package models + +import ( + "database/sql" + "encoding/json" + "fmt" + + "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/jmoiron/modl" + "github.com/thermokarst/bactdb/errors" + "github.com/thermokarst/bactdb/helpers" + "github.com/thermokarst/bactdb/types" +) + +func init() { + DB.AddTableWithName(MeasurementBase{}, "measurements").SetKeys(true, "ID") +} + +// PreInsert is a modl hook. +func (m *MeasurementBase) PreInsert(e modl.SqlExecutor) error { + ct := helpers.CurrentTime() + m.CreatedAt = ct + m.UpdatedAt = ct + return nil +} + +// PreUpdate is a modl hook. +func (m *MeasurementBase) PreUpdate(e modl.SqlExecutor) error { + m.UpdatedAt = helpers.CurrentTime() + return nil +} + +// MeasurementBase is what the DB expects for write operations +// There are three types of supported measurements: fixed-text, free-text, +// & numerical. The table has a constraint that will allow at most one +// for a particular combination of strain & characteristic. +type MeasurementBase struct { + ID int64 `json:"id,omitempty"` + StrainID int64 `db:"strain_id" json:"strain"` + CharacteristicID int64 `db:"characteristic_id" json:"characteristic"` + TextMeasurementTypeID types.NullInt64 `db:"text_measurement_type_id" json:"-"` + TxtValue types.NullString `db:"txt_value" json:"-"` + NumValue types.NullFloat64 `db:"num_value" json:"-"` + ConfidenceInterval types.NullFloat64 `db:"confidence_interval" json:"confidenceInterval"` + UnitTypeID types.NullInt64 `db:"unit_type_id" json:"-"` + Notes types.NullString `db:"notes" json:"notes"` + TestMethodID types.NullInt64 `db:"test_method_id" json:"-"` + CreatedAt types.NullTime `db:"created_at" json:"createdAt"` + UpdatedAt types.NullTime `db:"updated_at" json:"updatedAt"` + CreatedBy int64 `db:"created_by" json:"createdBy"` + UpdatedBy int64 `db:"updated_by" json:"updatedBy"` +} + +// Measurement is what the DB expects for read operations, and is what the API +// expects to return to the requester. +type Measurement struct { + *MeasurementBase + TextMeasurementType types.NullString `db:"text_measurement_type_name" json:"-"` + UnitType types.NullString `db:"unit_type_name" json:"unitType"` + TestMethod types.NullString `db:"test_method_name" json:"testMethod"` + CanEdit bool `db:"-" json:"canEdit"` +} + +// FakeMeasurement is a dummy struct to prevent infinite-loop/stack overflow on serialization. +type FakeMeasurement Measurement + +// MarshalJSON is custom JSON serialization to handle multi-type "Value". +func (m *Measurement) MarshalJSON() ([]byte, error) { + fm := FakeMeasurement(*m) + return json.Marshal(struct { + *FakeMeasurement + Value string `json:"value"` + }{ + FakeMeasurement: &fm, + Value: m.Value(), + }) +} + +// UnmarshalJSON is custom JSON deserialization to handle multi-type "Value" +func (m *Measurement) UnmarshalJSON(b []byte) error { + var measurement struct { + FakeMeasurement + Value interface{} `json:"value"` + } + if err := json.Unmarshal(b, &measurement); err != nil { + return err + } + + switch v := measurement.Value.(type) { + case string: + // Test if actually a lookup + id, err := GetTextMeasurementTypeID(v) + if err != nil { + if err == sql.ErrNoRows { + measurement.TxtValue = types.NullString{sql.NullString{String: v, Valid: true}} + } else { + return err + } + } else { + measurement.TextMeasurementTypeID = types.NullInt64{sql.NullInt64{Int64: id, Valid: true}} + } + case int64: + measurement.NumValue = types.NullFloat64{sql.NullFloat64{Float64: float64(v), Valid: true}} + case float64: + measurement.NumValue = types.NullFloat64{sql.NullFloat64{Float64: v, Valid: true}} + } + + *m = Measurement(measurement.FakeMeasurement) + + return nil +} + +// Value returns the value of the measurement +func (m *Measurement) Value() string { + if m.TextMeasurementType.Valid { + return m.TextMeasurementType.String + } + if m.TxtValue.Valid { + return m.TxtValue.String + } + if m.NumValue.Valid { + return fmt.Sprintf("%f", m.NumValue.Float64) + } + return "" +} + +// Measurements are multiple measurement entities +type Measurements []*Measurement + +// MeasurementMeta stashes some metadata related to the entity +type MeasurementMeta struct { + CanAdd bool `json:"canAdd"` +} + +// ListMeasurements returns all measurements +func ListMeasurements(opt helpers.MeasurementListOptions, claims *types.Claims) (*Measurements, error) { + var vals []interface{} + + q := `SELECT m.*, t.text_measurement_name AS text_measurement_type_name, + u.symbol AS unit_type_name, te.name AS test_method_name + FROM measurements m + INNER JOIN strains st ON st.id=m.strain_id + INNER JOIN species sp ON sp.id=st.species_id + INNER JOIN genera g ON g.id=sp.genus_id AND LOWER(g.genus_name)=$1 + LEFT OUTER JOIN characteristics c ON c.id=m.characteristic_id + LEFT OUTER JOIN text_measurement_types t ON t.id=m.text_measurement_type_id + LEFT OUTER JOIN unit_types u ON u.id=m.unit_type_id + LEFT OUTER JOIN test_methods te ON te.id=m.test_method_id` + vals = append(vals, opt.Genus) + + strainIDs := len(opt.Strains) != 0 + charIDs := len(opt.Characteristics) != 0 + ids := len(opt.IDs) != 0 + + if strainIDs || charIDs || ids { + var paramsCounter int64 = 2 + q += "\nWHERE (" + + // Filter by strains + if strainIDs { + q += helpers.ValsIn("st.id", opt.Strains, &vals, ¶msCounter) + } + + if strainIDs && (charIDs || ids) { + q += " AND " + } + + // Filter by characteristics + if charIDs { + q += helpers.ValsIn("c.id", opt.Characteristics, &vals, ¶msCounter) + } + + if charIDs && ids { + q += " AND " + } + + // Get specific records + if ids { + q += helpers.ValsIn("m.id", opt.IDs, &vals, ¶msCounter) + } + q += ")" + } + q += ";" + + measurements := make(Measurements, 0) + err := DBH.Select(&measurements, q, vals...) + if err != nil { + return nil, err + } + + for _, m := range measurements { + m.CanEdit = helpers.CanEdit(claims, m.CreatedBy) + } + + return &measurements, nil +} + +// GetMeasurement returns a particular measurement. +func GetMeasurement(id int64, genus string, claims *types.Claims) (*Measurement, error) { + var measurement Measurement + + q := `SELECT m.*, t.text_measurement_name AS text_measurement_type_name, + u.symbol AS unit_type_name, te.name AS test_method_name + FROM measurements m + INNER JOIN strains st ON st.id=m.strain_id + INNER JOIN species sp ON sp.id=st.species_id + INNER JOIN genera g ON g.id=sp.genus_id AND LOWER(g.genus_name)=LOWER($1) + LEFT OUTER JOIN characteristics c ON c.id=m.characteristic_id + LEFT OUTER JOIN text_measurement_types t ON t.id=m.text_measurement_type_id + LEFT OUTER JOIN unit_types u ON u.id=m.unit_type_id + LEFT OUTER JOIN test_methods te ON te.id=m.test_method_id + WHERE m.id=$2;` + if err := DBH.SelectOne(&measurement, q, genus, id); err != nil { + if err == sql.ErrNoRows { + return nil, errors.ErrMeasurementNotFound + } + return nil, err + } + + measurement.CanEdit = helpers.CanEdit(claims, measurement.CreatedBy) + + return &measurement, nil +} + +// CharacteristicOptsFromMeasurements returns the options for finding all related +// characteristics for a set of measurements. +func CharacteristicOptsFromMeasurements(opt helpers.MeasurementListOptions) (*helpers.ListOptions, error) { + return &helpers.ListOptions{Genus: opt.Genus, IDs: opt.Characteristics}, nil +} + +// StrainOptsFromMeasurements returns the options for finding all related +// strains from a set of measurements. +func StrainOptsFromMeasurements(opt helpers.MeasurementListOptions) (*helpers.ListOptions, error) { + return &helpers.ListOptions{Genus: opt.Genus, IDs: opt.Strains}, nil +} + +// GetTextMeasurementTypeID returns the ID for a particular text measurement type +func GetTextMeasurementTypeID(val string) (int64, error) { + var id int64 + q := `SELECT id FROM text_measurement_types WHERE text_measurement_name=$1;` + + if err := DBH.SelectOne(&id, q, val); err != nil { + return 0, err + } + return id, nil +} diff --git a/models/species.go b/models/species.go new file mode 100644 index 0000000..1992e49 --- /dev/null +++ b/models/species.go @@ -0,0 +1,183 @@ +package models + +import ( + "database/sql" + "fmt" + "strings" + + "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/jmoiron/modl" + "github.com/thermokarst/bactdb/errors" + "github.com/thermokarst/bactdb/helpers" + "github.com/thermokarst/bactdb/types" +) + +func init() { + DB.AddTableWithName(SpeciesBase{}, "species").SetKeys(true, "ID") +} + +// PreInsert is a modl hook. +func (s *SpeciesBase) PreInsert(e modl.SqlExecutor) error { + ct := helpers.CurrentTime() + s.CreatedAt = ct + s.UpdatedAt = ct + return nil +} + +// PreUpdate is a modl hook. +func (s *SpeciesBase) PreUpdate(e modl.SqlExecutor) error { + s.UpdatedAt = helpers.CurrentTime() + return nil +} + +// SpeciesBase is what the DB expects for write operations. +type SpeciesBase struct { + ID int64 `db:"id" json:"id"` + GenusID int64 `db:"genus_id" json:"-"` + SubspeciesSpeciesID types.NullInt64 `db:"subspecies_species_id" json:"-"` + SpeciesName string `db:"species_name" json:"speciesName"` + TypeSpecies types.NullBool `db:"type_species" json:"typeSpecies"` + Etymology types.NullString `db:"etymology" json:"etymology"` + CreatedAt types.NullTime `db:"created_at" json:"createdAt"` + UpdatedAt types.NullTime `db:"updated_at" json:"updatedAt"` + DeletedAt types.NullTime `db:"deleted_at" json:"deletedAt"` + CreatedBy int64 `db:"created_by" json:"createdBy"` + UpdatedBy int64 `db:"updated_by" json:"updatedBy"` + DeletedBy types.NullInt64 `db:"deleted_by" json:"deletedBy"` +} + +// Species is what the DB expects for read operations, and is what the API expects +// to return to the requester. +type Species struct { + *SpeciesBase + GenusName string `db:"genus_name" json:"genusName"` + Strains types.NullSliceInt64 `db:"strains" json:"strains"` + TotalStrains int64 `db:"total_strains" json:"totalStrains"` + SortOrder int64 `db:"sort_order" json:"sortOrder"` + CanEdit bool `db:"-" json:"canEdit"` +} + +// ManySpecies is multiple species entities. +type ManySpecies []*Species + +// SpeciesMeta stashes some metadata related to the entity. +type SpeciesMeta struct { + CanAdd bool `json:"canAdd"` +} + +// GenusIDFromName looks up the genus' ID. +func GenusIDFromName(genusName string) (int64, error) { + var genusID struct{ ID int64 } + q := `SELECT id FROM genera WHERE LOWER(genus_name) = LOWER($1);` + if err := DBH.SelectOne(&genusID, q, genusName); err != nil { + return 0, err + } + return genusID.ID, nil +} + +// StrainOptsFromSpecies returns the options for finding all related strains for +// a set of species. +func StrainOptsFromSpecies(opt helpers.ListOptions) (*helpers.ListOptions, error) { + var relatedStrainIDs []int64 + + if opt.IDs == nil { + q := `SELECT DISTINCT st.id + FROM strains st + INNER JOIN species sp ON sp.id=st.species_id + INNER JOIN genera g ON g.id=sp.genus_id AND LOWER(g.genus_name)=LOWER($1);` + if err := DBH.Select(&relatedStrainIDs, q, opt.Genus); err != nil { + return nil, err + } + } else { + var vals []interface{} + var count int64 = 1 + q := fmt.Sprintf("SELECT DISTINCT id FROM strains WHERE %s;", helpers.ValsIn("species_id", opt.IDs, &vals, &count)) + + if err := DBH.Select(&relatedStrainIDs, q, vals...); err != nil { + return nil, err + } + } + + return &helpers.ListOptions{Genus: opt.Genus, IDs: relatedStrainIDs}, nil +} + +// StrainsFromSpeciesID returns the options for finding all related strains for a +// particular species. +func StrainsFromSpeciesID(id int64, genus string, claims *types.Claims) (*Strains, error) { + opt := helpers.ListOptions{ + Genus: genus, + IDs: []int64{id}, + } + + strainsOpt, err := StrainOptsFromSpecies(opt) + if err != nil { + return nil, err + } + + strains, err := ListStrains(*strainsOpt, claims) + if err != nil { + return nil, err + } + + return strains, nil +} + +// ListSpecies returns all species +func ListSpecies(opt helpers.ListOptions, claims *types.Claims) (*ManySpecies, error) { + var vals []interface{} + + q := `SELECT sp.*, g.genus_name, array_agg(st.id) AS strains, + COUNT(st) AS total_strains, + rank() OVER (ORDER BY sp.species_name ASC) AS sort_order + FROM species sp + INNER JOIN genera g ON g.id=sp.genus_id AND LOWER(g.genus_name)=LOWER($1) + LEFT OUTER JOIN strains st ON st.species_id=sp.id` + vals = append(vals, opt.Genus) + + if len(opt.IDs) != 0 { + var conds []string + s := "sp.id IN (" + for i, id := range opt.IDs { + s = s + fmt.Sprintf("$%v,", i+2) // start param index at 2 + vals = append(vals, id) + } + s = s[:len(s)-1] + ")" + conds = append(conds, s) + q += " WHERE (" + strings.Join(conds, ") AND (") + ")" + } + + q += " GROUP BY sp.id, g.genus_name;" + + species := make(ManySpecies, 0) + err := DBH.Select(&species, q, vals...) + if err != nil { + return nil, err + } + + for _, s := range species { + s.CanEdit = helpers.CanEdit(claims, s.CreatedBy) + } + + return &species, nil +} + +// GetSpecies returns a particular species. +func GetSpecies(id int64, genus string, claims *types.Claims) (*Species, error) { + var species Species + q := `SELECT sp.*, g.genus_name, array_agg(st.id) AS strains, + COUNT(st) AS total_strains, 0 AS sort_order + FROM species sp + INNER JOIN genera g ON g.id=sp.genus_id AND LOWER(g.genus_name)=LOWER($1) + LEFT OUTER JOIN strains st ON st.species_id=sp.id + WHERE sp.id=$2 + GROUP BY sp.id, g.genus_name;` + if err := DBH.SelectOne(&species, q, genus, id); err != nil { + if err == sql.ErrNoRows { + return nil, errors.ErrSpeciesNotFound + } + return nil, err + } + + species.CanEdit = helpers.CanEdit(claims, species.CreatedBy) + + return &species, nil +} diff --git a/models/strains.go b/models/strains.go new file mode 100644 index 0000000..b314d39 --- /dev/null +++ b/models/strains.go @@ -0,0 +1,193 @@ +package models + +import ( + "database/sql" + "fmt" + "strings" + + "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/jmoiron/modl" + "github.com/thermokarst/bactdb/errors" + "github.com/thermokarst/bactdb/helpers" + "github.com/thermokarst/bactdb/types" +) + +func init() { + DB.AddTableWithName(StrainBase{}, "strains").SetKeys(true, "ID") +} + +// PreInsert is a modl hook. +func (s *StrainBase) PreInsert(e modl.SqlExecutor) error { + ct := helpers.CurrentTime() + s.CreatedAt = ct + s.UpdatedAt = ct + return nil +} + +// PreUpdate is a modl hook. +func (s *StrainBase) PreUpdate(e modl.SqlExecutor) error { + s.UpdatedAt = helpers.CurrentTime() + return nil +} + +// StrainBase is what the DB expects for write operations. +type StrainBase struct { + ID int64 `db:"id" json:"id"` + SpeciesID int64 `db:"species_id" json:"species"` + StrainName string `db:"strain_name" json:"strainName"` + TypeStrain bool `db:"type_strain" json:"typeStrain"` + AccessionNumbers types.NullString `db:"accession_numbers" json:"accessionNumbers"` + Genbank types.NullString `db:"genbank" json:"genbank"` + WholeGenomeSequence types.NullString `db:"whole_genome_sequence" json:"wholeGenomeSequence"` + IsolatedFrom types.NullString `db:"isolated_from" json:"isolatedFrom"` + Notes types.NullString `db:"notes" json:"notes"` + CreatedAt types.NullTime `db:"created_at" json:"createdAt"` + UpdatedAt types.NullTime `db:"updated_at" json:"updatedAt"` + DeletedAt types.NullTime `db:"deleted_at" json:"deletedAt"` + CreatedBy int64 `db:"created_by" json:"createdBy"` + UpdatedBy int64 `db:"updated_by" json:"updatedBy"` + DeletedBy types.NullInt64 `db:"deleted_by" json:"deletedBy"` +} + +// Strain is what the DB expects for read operations, and is what the API expects +// to return to the requester. +type Strain struct { + *StrainBase + Measurements types.NullSliceInt64 `db:"measurements" json:"measurements"` + Characteristics types.NullSliceInt64 `db:"characteristics" json:"characteristics"` + TotalMeasurements int64 `db:"total_measurements" json:"totalMeasurements"` + SortOrder int64 `db:"sort_order" json:"sortOrder"` + CanEdit bool `db:"-" json:"canEdit"` +} + +// Strains are multiple strain entities. +type Strains []*Strain + +// StrainMeta stashes some metadata related to the entity. +type StrainMeta struct { + CanAdd bool `json:"canAdd"` +} + +// SpeciesName returns a strain's species name. +func (s StrainBase) SpeciesName() string { + var species SpeciesBase + if err := DBH.Get(&species, s.SpeciesID); err != nil { + return "" + } + return species.SpeciesName +} + +// ListStrains returns all strains. +func ListStrains(opt helpers.ListOptions, claims *types.Claims) (*Strains, error) { + var vals []interface{} + + q := `SELECT st.*, array_agg(m.id) AS measurements, + array_agg(DISTINCT m.characteristic_id) AS characteristics, + COUNT(m) AS total_measurements, + rank() OVER (ORDER BY sp.species_name ASC, st.type_strain ASC, st.strain_name ASC) AS sort_order + FROM strains st + INNER JOIN species sp ON sp.id=st.species_id + INNER JOIN genera g ON g.id=sp.genus_id AND LOWER(g.genus_name)=LOWER($1) + LEFT OUTER JOIN measurements m ON m.strain_id=st.id` + vals = append(vals, opt.Genus) + + if len(opt.IDs) != 0 { + var conds []string + s := "st.id IN (" + for i, id := range opt.IDs { + s = s + fmt.Sprintf("$%v,", i+2) // start param index at 2 + vals = append(vals, id) + } + s = s[:len(s)-1] + ")" + conds = append(conds, s) + q += " WHERE (" + strings.Join(conds, ") AND (") + ")" + } + + q += " GROUP BY st.id, st.species_id, sp.species_name;" + + strains := make(Strains, 0) + err := DBH.Select(&strains, q, vals...) + if err != nil { + return nil, err + } + + for _, s := range strains { + s.CanEdit = helpers.CanEdit(claims, s.CreatedBy) + } + + return &strains, nil +} + +// GetStrain returns a particular strain. +func GetStrain(id int64, genus string, claims *types.Claims) (*Strain, error) { + var strain Strain + q := `SELECT st.*, array_agg(DISTINCT m.id) AS measurements, + array_agg(DISTINCT m.characteristic_id) AS characteristics, + COUNT(m) AS total_measurements, 0 AS sort_order + FROM strains st + INNER JOIN species sp ON sp.id=st.species_id + INNER JOIN genera g ON g.id=sp.genus_id AND LOWER(g.genus_name)=LOWER($1) + LEFT OUTER JOIN measurements m ON m.strain_id=st.id + WHERE st.id=$2 + GROUP BY st.id;` + if err := DBH.SelectOne(&strain, q, genus, id); err != nil { + if err == sql.ErrNoRows { + return nil, errors.ErrStrainNotFound + } + return nil, err + } + + strain.CanEdit = helpers.CanEdit(claims, strain.CreatedBy) + + return &strain, nil +} + +// SpeciesOptsFromStrains returns the options for finding all related species for a +// set of strains. +func SpeciesOptsFromStrains(opt helpers.ListOptions) (*helpers.ListOptions, error) { + var relatedSpeciesIDs []int64 + + if opt.IDs == nil || len(opt.IDs) == 0 { + q := `SELECT DISTINCT st.species_id + FROM strains st + INNER JOIN species sp ON sp.id=st.species_id + INNER JOIN genera g ON g.id=sp.genus_id AND LOWER(g.genus_name)=LOWER($1);` + if err := DBH.Select(&relatedSpeciesIDs, q, opt.Genus); err != nil { + return nil, err + } + } else { + var vals []interface{} + var count int64 = 1 + q := fmt.Sprintf("SELECT DISTINCT species_id FROM strains WHERE %s;", helpers.ValsIn("id", opt.IDs, &vals, &count)) + if err := DBH.Select(&relatedSpeciesIDs, q, vals...); err != nil { + return nil, err + } + } + + return &helpers.ListOptions{Genus: opt.Genus, IDs: relatedSpeciesIDs}, nil +} + +// CharacteristicsOptsFromStrains returns the options for finding all related +// characteristics for a set of strains. +func CharacteristicsOptsFromStrains(opt helpers.ListOptions) (*helpers.ListOptions, error) { + var relatedCharacteristicsIDs []int64 + + if opt.IDs == nil || len(opt.IDs) == 0 { + q := `SELECT DISTINCT m.characteristic_id + FROM measurements m + INNER JOIN strains st ON st.id=m.strain_id + INNER JOIN species sp ON sp.id=st.species_id + INNER JOIN genera g ON g.id=sp.genus_id AND LOWER(g.genus_name)=LOWER($1);` + if err := DBH.Select(&relatedCharacteristicsIDs, q, opt.Genus); err != nil { + return nil, err + } + } else { + var vals []interface{} + var count int64 = 1 + q := fmt.Sprintf("SELECT DISTINCT characteristic_id FROM measurements WHERE %s;", helpers.ValsIn("strain_id", opt.IDs, &vals, &count)) + if err := DBH.Select(&relatedCharacteristicsIDs, q, vals...); err != nil { + return nil, err + } + } + + return &helpers.ListOptions{Genus: opt.Genus, IDs: relatedCharacteristicsIDs}, nil +} diff --git a/models/users.go b/models/users.go new file mode 100644 index 0000000..b4f28ad --- /dev/null +++ b/models/users.go @@ -0,0 +1,148 @@ +package models + +import ( + "database/sql" + "encoding/json" + "regexp" + + "github.com/thermokarst/bactdb/Godeps/_workspace/src/golang.org/x/crypto/bcrypt" + "github.com/thermokarst/bactdb/errors" + "github.com/thermokarst/bactdb/helpers" + "github.com/thermokarst/bactdb/types" +) + +func init() { + DB.AddTableWithName(UserBase{}, "users").SetKeys(true, "ID") +} + +// UserBase is what the DB expects to see for write operations. +type UserBase struct { + ID int64 `json:"id,omitempty"` + Email string `db:"email" json:"email"` + Password string `db:"password" json:"password,omitempty"` + Name string `db:"name" json:"name"` + Role string `db:"role" json:"role"` + Verified bool `db:"verified" json:"-"` + CreatedAt types.NullTime `db:"created_at" json:"createdAt"` + UpdatedAt types.NullTime `db:"updated_at" json:"updatedAt"` + DeletedAt types.NullTime `db:"deleted_at" json:"deletedAt"` +} + +// User is what the DB expects to see for read operations, and is what the API +// expects to return to the requester. +type User struct { + *UserBase + CanEdit bool `db:"-" json:"canEdit"` +} + +// UserValidation handles validation of a user record. +type UserValidation struct { + Email []string `json:"email,omitempty"` + Password []string `json:"password,omitempty"` + Name []string `json:"name,omitempty"` + Role []string `json:"role,omitempty"` +} + +// Error returns the JSON-encoded error response for any validation errors. +func (uv UserValidation) Error() string { + errs, err := json.Marshal(struct { + UserValidation `json:"errors"` + }{uv}) + if err != nil { + return err.Error() + } + return string(errs) +} + +// Users are multiple user entities. +type Users []*User + +// UserMeta stashes some metadata related to the entity. +type UserMeta struct { + CanAdd bool `json:"canAdd"` +} + +// Validate validates a user record. +func (u *User) Validate() error { + var uv UserValidation + validationError := false + + if u.Name == "" { + uv.Name = append(uv.Name, helpers.MustProvideAValue) + validationError = true + } + + if u.Email == "" { + uv.Email = append(uv.Email, helpers.MustProvideAValue) + validationError = true + } + + regex, _ := regexp.Compile(`(\w[-._\w]*\w@\w[-._\w]*\w\.\w{2,3})`) + if u.Email != "" && !regex.MatchString(u.Email) { + uv.Email = append(uv.Email, "Must provide a valid email address") + validationError = true + } + + if len(u.Password) < 8 { + uv.Password = append(uv.Password, "Password must be at least 8 characters") + validationError = true + } + + if validationError { + return uv + } + return nil +} + +// DbAuthenticate authenticates a user. +// For thermokarst/jwt: authentication callback +func DbAuthenticate(email string, password string) error { + var user User + q := `SELECT * + FROM users + WHERE lower(email)=lower($1) + AND verified IS TRUE + AND deleted_at IS NULL;` + if err := DBH.SelectOne(&user, q, email); err != nil { + return errors.ErrInvalidEmailOrPassword + } + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil { + return errors.ErrInvalidEmailOrPassword + } + return nil +} + +// DbGetUserByID returns a specific user record by ID. +func DbGetUserByID(id int64) (*User, error) { + var user User + q := `SELECT * + FROM users + WHERE id=$1 + AND verified IS TRUE + AND deleted_at IS NULL;` + if err := DBH.SelectOne(&user, q, id); err != nil { + if err == sql.ErrNoRows { + return nil, errors.ErrUserNotFound + } + return nil, err + } + return &user, nil +} + +// DbGetUserByEmail returns a specific user record by email. +// For thermokarst/jwt: setting user in claims bundle +func DbGetUserByEmail(email string) (*User, error) { + var user User + q := `SELECT * + FROM users + WHERE lower(email)=lower($1) + AND verified IS TRUE + AND deleted_at IS NULL;` + if err := DBH.SelectOne(&user, q, email); err != nil { + if err == sql.ErrNoRows { + return nil, errors.ErrUserNotFound + } + return nil, err + } + return &user, nil +} diff --git a/payloads/characteristics.go b/payloads/characteristics.go new file mode 100644 index 0000000..5c7273d --- /dev/null +++ b/payloads/characteristics.go @@ -0,0 +1,37 @@ +package payloads + +import ( + "encoding/json" + + "github.com/thermokarst/bactdb/models" +) + +// Characteristic is a payload that sideloads all of the necessary entities for +// a particular characteristic. +type Characteristic struct { + Characteristic *models.Characteristic `json:"characteristic"` + Measurements *models.Measurements `json:"measurements"` + Strains *models.Strains `json:"strains"` + Species *models.ManySpecies `json:"species"` + Meta *models.CharacteristicMeta `json:"meta"` +} + +// Characteristics is a payload that sideloads all of the necessary entities for +// multiple characteristics. +type Characteristics struct { + Characteristics *models.Characteristics `json:"characteristics"` + Measurements *models.Measurements `json:"measurements"` + Strains *models.Strains `json:"strains"` + Species *models.ManySpecies `json:"species"` + Meta *models.CharacteristicMeta `json:"meta"` +} + +// Marshal satisfies the CRUD interfaces. +func (c *Characteristic) Marshal() ([]byte, error) { + return json.Marshal(c) +} + +// Marshal satisfies the CRUD interfaces. +func (c *Characteristics) Marshal() ([]byte, error) { + return json.Marshal(c) +} diff --git a/payloads/measurements.go b/payloads/measurements.go new file mode 100644 index 0000000..307c76b --- /dev/null +++ b/payloads/measurements.go @@ -0,0 +1,31 @@ +package payloads + +import ( + "encoding/json" + + "github.com/thermokarst/bactdb/models" +) + +// Measurement is a payload that sideloads all of the necessary entities for +// a particular measurement. +type Measurement struct { + Measurement *models.Measurement `json:"measurement"` +} + +// Measurements is a payload that sideloads all of the necessary entities for +// multiple measurements. +type Measurements struct { + Strains *models.Strains `json:"strains"` + Characteristics *models.Characteristics `json:"characteristics"` + Measurements *models.Measurements `json:"measurements"` +} + +// Marshal satisfies the CRUD interfaces. +func (m *Measurement) Marshal() ([]byte, error) { + return json.Marshal(m) +} + +// Marshal satisfies the CRUD interfaces. +func (m *Measurements) Marshal() ([]byte, error) { + return json.Marshal(m) +} diff --git a/payloads/species.go b/payloads/species.go new file mode 100644 index 0000000..376bf4c --- /dev/null +++ b/payloads/species.go @@ -0,0 +1,33 @@ +package payloads + +import ( + "encoding/json" + + "github.com/thermokarst/bactdb/models" +) + +// Species is a payload that sideloads all of the necessary entities for a +// particular species. +type Species struct { + Species *models.Species `json:"species"` + Strains *models.Strains `json:"strains"` + Meta *models.SpeciesMeta `json:"meta"` +} + +// ManySpecies is a payload that sideloads all of the necessary entities for +// multiple species. +type ManySpecies struct { + Species *models.ManySpecies `json:"species"` + Strains *models.Strains `json:"strains"` + Meta *models.SpeciesMeta `json:"meta"` +} + +// Marshal satisfies the CRUD interfaces. +func (s *Species) Marshal() ([]byte, error) { + return json.Marshal(s) +} + +// Marshal satisfies the CRUD interfaces. +func (s *ManySpecies) Marshal() ([]byte, error) { + return json.Marshal(s) +} diff --git a/payloads/strains.go b/payloads/strains.go new file mode 100644 index 0000000..57f00af --- /dev/null +++ b/payloads/strains.go @@ -0,0 +1,37 @@ +package payloads + +import ( + "encoding/json" + + "github.com/thermokarst/bactdb/models" +) + +// Strain is a payload that sideloads all of the necessary entities for a +// particular strain. +type Strain struct { + Strain *models.Strain `json:"strain"` + Species *models.ManySpecies `json:"species"` + Characteristics *models.Characteristics `json:"characteristics"` + Measurements *models.Measurements `json:"measurements"` + Meta *models.StrainMeta `json:"meta"` +} + +// Strains is a payload that sideloads all of the necessary entities for +// multiple strains. +type Strains struct { + Strains *models.Strains `json:"strains"` + Species *models.ManySpecies `json:"species"` + Characteristics *models.Characteristics `json:"characteristics"` + Measurements *models.Measurements `json:"measurements"` + Meta *models.StrainMeta `json:"meta"` +} + +// Marshal satisfies the CRUD interfaces. +func (s *Strain) Marshal() ([]byte, error) { + return json.Marshal(s) +} + +// Marshal satisfies the CRUD interfaces. +func (s *Strains) Marshal() ([]byte, error) { + return json.Marshal(s) +} diff --git a/payloads/users.go b/payloads/users.go new file mode 100644 index 0000000..8c0e9b8 --- /dev/null +++ b/payloads/users.go @@ -0,0 +1,31 @@ +package payloads + +import ( + "encoding/json" + + "github.com/thermokarst/bactdb/models" +) + +// User is a payload that sideloads all of the necessary entities for a +// particular user. +type User struct { + User *models.User `json:"user"` + Meta *models.UserMeta `json:"meta"` +} + +// Users is a payload that sideloads all of the necessary entities for +// multiple users. +type Users struct { + Users *models.Users `json:"users"` + Meta *models.UserMeta `json:"meta"` +} + +// Marshal satisfies the CRUD interfaces. +func (u *User) Marshal() ([]byte, error) { + return json.Marshal(u) +} + +// Marshal satisfies the CRUD interfaces. +func (u *Users) Marshal() ([]byte, error) { + return json.Marshal(u) +} diff --git a/session.json b/session.json deleted file mode 100644 index 9d0d8df..0000000 --- a/session.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "__meta__": { - "about": "HTTPie session file", - "help": "https://github.com/jakubroztocil/httpie#sessions", - "httpie": "0.9.1" - }, - "auth": { - "password": null, - "type": null, - "username": null - }, - "cookies": {}, - "headers": { - "Accept": "application/json", - "Authorization": "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE0Mjc1NzAyNDIsImdlbnVzIjoiaHltZW5vYmFjdGVyIiwiaWF0IjoxNDI3NDgzODQyLCJpc3MiOiJiYWN0ZGIiLCJuYW1lIjoidGVzdCIsInJvbGUiOiJhZG1pbiIsInN1YiI6InVzZXJAZXhhbXBsZS5jb20ifQ.PTQsRsWz9R2R8gNhrtmyRw9irJVMbUGjuIj-PRIk_Ht-tBj36cgnc9VSOwUZX-_UUKnF6A6HQqkcQ9AQf4GKfQzU3RMXNxw7afjhM1c4_D4zK6V7Q3JIpVGxJQ7kKalcP4hxmrpZCMJveCPVS_Wbz6H2bhqOsf4peWBWUTuoOQU" - } -} diff --git a/species.go b/species.go deleted file mode 100644 index 3aa0f45..0000000 --- a/species.go +++ /dev/null @@ -1,330 +0,0 @@ -package main - -import ( - "database/sql" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/url" - "strings" - - "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/jmoiron/modl" -) - -var ( - ErrSpeciesNotFound = errors.New("Species not found") - ErrSpeciesNotUpdated = errors.New("Species not updated") -) - -func init() { - DB.AddTableWithName(SpeciesBase{}, "species").SetKeys(true, "Id") -} - -func (s *SpeciesBase) PreInsert(e modl.SqlExecutor) error { - ct := currentTime() - s.CreatedAt = ct - s.UpdatedAt = ct - return nil -} - -func (s *SpeciesBase) PreUpdate(e modl.SqlExecutor) error { - s.UpdatedAt = currentTime() - return nil -} - -type SpeciesService struct{} - -type SpeciesBase struct { - Id int64 `db:"id" json:"id"` - GenusID int64 `db:"genus_id" json:"-"` - SubspeciesSpeciesID NullInt64 `db:"subspecies_species_id" json:"-"` - SpeciesName string `db:"species_name" json:"speciesName"` - TypeSpecies NullBool `db:"type_species" json:"typeSpecies"` - Etymology NullString `db:"etymology" json:"etymology"` - CreatedAt NullTime `db:"created_at" json:"createdAt"` - UpdatedAt NullTime `db:"updated_at" json:"updatedAt"` - DeletedAt NullTime `db:"deleted_at" json:"deletedAt"` - CreatedBy int64 `db:"created_by" json:"createdBy"` - UpdatedBy int64 `db:"updated_by" json:"updatedBy"` - DeletedBy NullInt64 `db:"deleted_by" json:"deletedBy"` -} - -type Species struct { - *SpeciesBase - GenusName string `db:"genus_name" json:"genusName"` - Strains NullSliceInt64 `db:"strains" json:"strains"` - TotalStrains int64 `db:"total_strains" json:"totalStrains"` - SortOrder int64 `db:"sort_order" json:"sortOrder"` - CanEdit bool `db:"-" json:"canEdit"` -} - -type ManySpecies []*Species - -type SpeciesMeta struct { - CanAdd bool `json:"canAdd"` -} - -type SpeciesPayload struct { - Species *Species `json:"species"` - Strains *Strains `json:"strains"` - Meta *SpeciesMeta `json:"meta"` -} - -type ManySpeciesPayload struct { - Species *ManySpecies `json:"species"` - Strains *Strains `json:"strains"` - Meta *SpeciesMeta `json:"meta"` -} - -func (s *SpeciesPayload) marshal() ([]byte, error) { - return json.Marshal(s) -} - -func (s *ManySpeciesPayload) marshal() ([]byte, error) { - return json.Marshal(s) -} - -func (s SpeciesService) unmarshal(b []byte) (entity, error) { - var sj SpeciesPayload - err := json.Unmarshal(b, &sj) - return &sj, err -} - -func (s SpeciesService) list(val *url.Values, claims *Claims) (entity, *appError) { - if val == nil { - return nil, ErrMustProvideOptionsJSON - } - var opt ListOptions - if err := schemaDecoder.Decode(&opt, *val); err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - species, err := listSpecies(opt, claims) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - strains_opt, err := strainOptsFromSpecies(opt) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - strains, err := listStrains(*strains_opt, claims) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - payload := ManySpeciesPayload{ - Species: species, - Strains: strains, - Meta: &SpeciesMeta{ - CanAdd: canAdd(claims), - }, - } - - return &payload, nil -} - -func (s SpeciesService) get(id int64, genus string, claims *Claims) (entity, *appError) { - species, err := getSpecies(id, genus, claims) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - strains, err := strainsFromSpeciesId(id, genus, claims) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - payload := SpeciesPayload{ - Species: species, - Strains: strains, - Meta: &SpeciesMeta{ - CanAdd: canAdd(claims), - }, - } - - return &payload, nil -} - -func (s SpeciesService) update(id int64, e *entity, genus string, claims *Claims) *appError { - payload := (*e).(*SpeciesPayload) - payload.Species.UpdatedBy = claims.Sub - payload.Species.Id = id - - genus_id, err := genusIdFromName(genus) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - payload.Species.SpeciesBase.GenusID = genus_id - - count, err := DBH.Update(payload.Species.SpeciesBase) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - if count != 1 { - return newJSONError(ErrSpeciesNotUpdated, http.StatusBadRequest) - } - - // Reload to send back down the wire - species, err := getSpecies(id, genus, claims) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - - strains, err := strainsFromSpeciesId(id, genus, claims) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - - payload.Species = species - payload.Strains = strains - payload.Meta = &SpeciesMeta{ - CanAdd: canAdd(claims), - } - - return nil -} - -func (s SpeciesService) create(e *entity, genus string, claims *Claims) *appError { - payload := (*e).(*SpeciesPayload) - payload.Species.CreatedBy = claims.Sub - payload.Species.UpdatedBy = claims.Sub - - genus_id, err := genusIdFromName(genus) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - payload.Species.SpeciesBase.GenusID = genus_id - - err = DBH.Insert(payload.Species.SpeciesBase) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - - // Reload to send back down the wire - species, err := getSpecies(payload.Species.Id, genus, claims) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - - // Note, no strains when new species - - payload.Species = species - payload.Meta = &SpeciesMeta{ - CanAdd: canAdd(claims), - } - return nil -} - -func genusIdFromName(genus_name string) (int64, error) { - var genus_id struct{ Id int64 } - q := `SELECT id FROM genera WHERE LOWER(genus_name) = LOWER($1);` - if err := DBH.SelectOne(&genus_id, q, genus_name); err != nil { - return 0, err - } - return genus_id.Id, nil -} - -func strainOptsFromSpecies(opt ListOptions) (*ListOptions, error) { - relatedStrainIds := make([]int64, 0) - - if opt.Ids == nil { - q := `SELECT DISTINCT st.id - FROM strains st - INNER JOIN species sp ON sp.id=st.species_id - INNER JOIN genera g ON g.id=sp.genus_id AND LOWER(g.genus_name)=LOWER($1);` - if err := DBH.Select(&relatedStrainIds, q, opt.Genus); err != nil { - return nil, err - } - } else { - var vals []interface{} - var count int64 = 1 - q := fmt.Sprintf("SELECT DISTINCT id FROM strains WHERE %s;", valsIn("species_id", opt.Ids, &vals, &count)) - - if err := DBH.Select(&relatedStrainIds, q, vals...); err != nil { - return nil, err - } - } - - return &ListOptions{Genus: opt.Genus, Ids: relatedStrainIds}, nil -} - -func strainsFromSpeciesId(id int64, genus string, claims *Claims) (*Strains, error) { - opt := ListOptions{ - Genus: genus, - Ids: []int64{id}, - } - - strains_opt, err := strainOptsFromSpecies(opt) - if err != nil { - return nil, err - } - - strains, err := listStrains(*strains_opt, claims) - if err != nil { - return nil, err - } - - return strains, nil -} - -func listSpecies(opt ListOptions, claims *Claims) (*ManySpecies, error) { - var vals []interface{} - - q := `SELECT sp.*, g.genus_name, array_agg(st.id) AS strains, - COUNT(st) AS total_strains, - rank() OVER (ORDER BY sp.species_name ASC) AS sort_order - FROM species sp - INNER JOIN genera g ON g.id=sp.genus_id AND LOWER(g.genus_name)=LOWER($1) - LEFT OUTER JOIN strains st ON st.species_id=sp.id` - vals = append(vals, opt.Genus) - - if len(opt.Ids) != 0 { - var conds []string - s := "sp.id IN (" - for i, id := range opt.Ids { - s = s + fmt.Sprintf("$%v,", i+2) // start param index at 2 - vals = append(vals, id) - } - s = s[:len(s)-1] + ")" - conds = append(conds, s) - q += " WHERE (" + strings.Join(conds, ") AND (") + ")" - } - - q += " GROUP BY sp.id, g.genus_name;" - - species := make(ManySpecies, 0) - err := DBH.Select(&species, q, vals...) - if err != nil { - return nil, err - } - - for _, s := range species { - s.CanEdit = canEdit(claims, s.CreatedBy) - } - - return &species, nil -} - -func getSpecies(id int64, genus string, claims *Claims) (*Species, error) { - var species Species - q := `SELECT sp.*, g.genus_name, array_agg(st.id) AS strains, - COUNT(st) AS total_strains, 0 AS sort_order - FROM species sp - INNER JOIN genera g ON g.id=sp.genus_id AND LOWER(g.genus_name)=LOWER($1) - LEFT OUTER JOIN strains st ON st.species_id=sp.id - WHERE sp.id=$2 - GROUP BY sp.id, g.genus_name;` - if err := DBH.SelectOne(&species, q, genus, id); err != nil { - if err == sql.ErrNoRows { - return nil, ErrSpeciesNotFound - } - return nil, err - } - - species.CanEdit = canEdit(claims, species.CreatedBy) - - return &species, nil -} diff --git a/strains.go b/strains.go deleted file mode 100644 index 7851e7d..0000000 --- a/strains.go +++ /dev/null @@ -1,406 +0,0 @@ -package main - -import ( - "database/sql" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/url" - "strings" - - "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/jmoiron/modl" -) - -var ( - ErrStrainNotFound = errors.New("Strain not found") - ErrStrainNotUpdated = errors.New("Strain not updated") -) - -func init() { - DB.AddTableWithName(StrainBase{}, "strains").SetKeys(true, "Id") -} - -func (s *StrainBase) PreInsert(e modl.SqlExecutor) error { - ct := currentTime() - s.CreatedAt = ct - s.UpdatedAt = ct - return nil -} - -func (s *StrainBase) PreUpdate(e modl.SqlExecutor) error { - s.UpdatedAt = currentTime() - return nil -} - -type StrainService struct{} - -type StrainBase struct { - Id int64 `db:"id" json:"id"` - SpeciesId int64 `db:"species_id" json:"species"` - StrainName string `db:"strain_name" json:"strainName"` - TypeStrain bool `db:"type_strain" json:"typeStrain"` - AccessionNumbers NullString `db:"accession_numbers" json:"accessionNumbers"` - Genbank NullString `db:"genbank" json:"genbank"` - WholeGenomeSequence NullString `db:"whole_genome_sequence" json:"wholeGenomeSequence"` - IsolatedFrom NullString `db:"isolated_from" json:"isolatedFrom"` - Notes NullString `db:"notes" json:"notes"` - CreatedAt NullTime `db:"created_at" json:"createdAt"` - UpdatedAt NullTime `db:"updated_at" json:"updatedAt"` - DeletedAt NullTime `db:"deleted_at" json:"deletedAt"` - CreatedBy int64 `db:"created_by" json:"createdBy"` - UpdatedBy int64 `db:"updated_by" json:"updatedBy"` - DeletedBy NullInt64 `db:"deleted_by" json:"deletedBy"` -} - -type Strain struct { - *StrainBase - Measurements NullSliceInt64 `db:"measurements" json:"measurements"` - Characteristics NullSliceInt64 `db:"characteristics" json:"characteristics"` - TotalMeasurements int64 `db:"total_measurements" json:"totalMeasurements"` - SortOrder int64 `db:"sort_order" json:"sortOrder"` - CanEdit bool `db:"-" json:"canEdit"` -} - -type Strains []*Strain - -type StrainMeta struct { - CanAdd bool `json:"canAdd"` -} - -type StrainPayload struct { - Strain *Strain `json:"strain"` - Species *ManySpecies `json:"species"` - Characteristics *Characteristics `json:"characteristics"` - Measurements *Measurements `json:"measurements"` - Meta *StrainMeta `json:"meta"` -} - -type StrainsPayload struct { - Strains *Strains `json:"strains"` - Species *ManySpecies `json:"species"` - Characteristics *Characteristics `json:"characteristics"` - Measurements *Measurements `json:"measurements"` - Meta *StrainMeta `json:"meta"` -} - -func (s *StrainPayload) marshal() ([]byte, error) { - return json.Marshal(s) -} - -func (s *StrainsPayload) marshal() ([]byte, error) { - return json.Marshal(s) -} - -func (s StrainService) unmarshal(b []byte) (entity, error) { - var sj StrainPayload - err := json.Unmarshal(b, &sj) - return &sj, err -} - -func (s StrainBase) SpeciesName() string { - var species SpeciesBase - if err := DBH.Get(&species, s.SpeciesId); err != nil { - return "" - } - return species.SpeciesName -} - -func (s StrainService) list(val *url.Values, claims *Claims) (entity, *appError) { - if val == nil { - return nil, ErrMustProvideOptionsJSON - } - var opt ListOptions - if err := schemaDecoder.Decode(&opt, *val); err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - strains, err := listStrains(opt, claims) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - species_opt, err := speciesOptsFromStrains(opt) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - species, err := listSpecies(*species_opt, claims) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - characteristics_opt, err := characteristicsOptsFromStrains(opt) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - characteristics, err := listCharacteristics(*characteristics_opt, claims) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - characteristic_ids := []int64{} - for _, c := range *characteristics { - characteristic_ids = append(characteristic_ids, c.Id) - } - - strain_ids := []int64{} - for _, s := range *strains { - strain_ids = append(strain_ids, s.Id) - } - - measurement_opt := MeasurementListOptions{ - ListOptions: ListOptions{ - Genus: opt.Genus, - }, - Strains: strain_ids, - Characteristics: characteristic_ids, - } - - measurements, err := listMeasurements(measurement_opt, claims) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - payload := StrainsPayload{ - Strains: strains, - Species: species, - Measurements: measurements, - Characteristics: characteristics, - Meta: &StrainMeta{ - CanAdd: canAdd(claims), - }, - } - - return &payload, nil -} - -func (s StrainService) get(id int64, genus string, claims *Claims) (entity, *appError) { - strain, err := getStrain(id, genus, claims) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - species, err := getSpecies(strain.SpeciesId, genus, claims) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - opt := ListOptions{Genus: genus, Ids: []int64{id}} - characteristics_opt, err := characteristicsOptsFromStrains(opt) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - characteristics, err := listCharacteristics(*characteristics_opt, claims) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - characteristic_ids := []int64{} - for _, c := range *characteristics { - characteristic_ids = append(characteristic_ids, c.Id) - } - - measurement_opt := MeasurementListOptions{ - ListOptions: ListOptions{ - Genus: genus, - }, - Strains: []int64{id}, - Characteristics: characteristic_ids, - } - - measurements, err := listMeasurements(measurement_opt, claims) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - var many_species ManySpecies = []*Species{species} - - payload := StrainPayload{ - Strain: strain, - Species: &many_species, - Characteristics: characteristics, - Measurements: measurements, - Meta: &StrainMeta{ - CanAdd: canAdd(claims), - }, - } - - return &payload, nil -} - -func (s StrainService) update(id int64, e *entity, genus string, claims *Claims) *appError { - payload := (*e).(*StrainPayload) - payload.Strain.UpdatedBy = claims.Sub - payload.Strain.Id = id - - count, err := DBH.Update(payload.Strain.StrainBase) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - if count != 1 { - return newJSONError(ErrStrainNotUpdated, http.StatusBadRequest) - } - - strain, err := getStrain(id, genus, claims) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - - species, err := getSpecies(strain.SpeciesId, genus, claims) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - - var many_species ManySpecies = []*Species{species} - - payload.Strain = strain - payload.Species = &many_species - payload.Meta = &StrainMeta{ - CanAdd: canAdd(claims), - } - - return nil -} - -func (s StrainService) create(e *entity, genus string, claims *Claims) *appError { - payload := (*e).(*StrainPayload) - payload.Strain.CreatedBy = claims.Sub - payload.Strain.UpdatedBy = claims.Sub - - if err := DBH.Insert(payload.Strain.StrainBase); err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - - strain, err := getStrain(payload.Strain.Id, genus, claims) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - - species, err := getSpecies(strain.SpeciesId, genus, claims) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - - var many_species ManySpecies = []*Species{species} - - payload.Strain = strain - payload.Species = &many_species - payload.Meta = &StrainMeta{ - CanAdd: canAdd(claims), - } - - return nil -} - -func listStrains(opt ListOptions, claims *Claims) (*Strains, error) { - var vals []interface{} - - q := `SELECT st.*, array_agg(m.id) AS measurements, - array_agg(DISTINCT m.characteristic_id) AS characteristics, - COUNT(m) AS total_measurements, - rank() OVER (ORDER BY sp.species_name ASC, st.type_strain ASC, st.strain_name ASC) AS sort_order - FROM strains st - INNER JOIN species sp ON sp.id=st.species_id - INNER JOIN genera g ON g.id=sp.genus_id AND LOWER(g.genus_name)=LOWER($1) - LEFT OUTER JOIN measurements m ON m.strain_id=st.id` - vals = append(vals, opt.Genus) - - if len(opt.Ids) != 0 { - var conds []string - s := "st.id IN (" - for i, id := range opt.Ids { - s = s + fmt.Sprintf("$%v,", i+2) // start param index at 2 - vals = append(vals, id) - } - s = s[:len(s)-1] + ")" - conds = append(conds, s) - q += " WHERE (" + strings.Join(conds, ") AND (") + ")" - } - - q += " GROUP BY st.id, st.species_id, sp.species_name;" - - strains := make(Strains, 0) - err := DBH.Select(&strains, q, vals...) - if err != nil { - return nil, err - } - - for _, s := range strains { - s.CanEdit = canEdit(claims, s.CreatedBy) - } - - return &strains, nil -} - -func getStrain(id int64, genus string, claims *Claims) (*Strain, error) { - var strain Strain - q := `SELECT st.*, array_agg(DISTINCT m.id) AS measurements, - array_agg(DISTINCT m.characteristic_id) AS characteristics, - COUNT(m) AS total_measurements, 0 AS sort_order - FROM strains st - INNER JOIN species sp ON sp.id=st.species_id - INNER JOIN genera g ON g.id=sp.genus_id AND LOWER(g.genus_name)=LOWER($1) - LEFT OUTER JOIN measurements m ON m.strain_id=st.id - WHERE st.id=$2 - GROUP BY st.id;` - if err := DBH.SelectOne(&strain, q, genus, id); err != nil { - if err == sql.ErrNoRows { - return nil, ErrStrainNotFound - } - return nil, err - } - - strain.CanEdit = canEdit(claims, strain.CreatedBy) - - return &strain, nil -} - -func speciesOptsFromStrains(opt ListOptions) (*ListOptions, error) { - relatedSpeciesIds := make([]int64, 0) - - if opt.Ids == nil || len(opt.Ids) == 0 { - q := `SELECT DISTINCT st.species_id - FROM strains st - INNER JOIN species sp ON sp.id=st.species_id - INNER JOIN genera g ON g.id=sp.genus_id AND LOWER(g.genus_name)=LOWER($1);` - if err := DBH.Select(&relatedSpeciesIds, q, opt.Genus); err != nil { - return nil, err - } - } else { - var vals []interface{} - var count int64 = 1 - q := fmt.Sprintf("SELECT DISTINCT species_id FROM strains WHERE %s;", valsIn("id", opt.Ids, &vals, &count)) - if err := DBH.Select(&relatedSpeciesIds, q, vals...); err != nil { - return nil, err - } - } - - return &ListOptions{Genus: opt.Genus, Ids: relatedSpeciesIds}, nil -} - -func characteristicsOptsFromStrains(opt ListOptions) (*ListOptions, error) { - relatedCharacteristicsIds := make([]int64, 0) - - if opt.Ids == nil || len(opt.Ids) == 0 { - q := `SELECT DISTINCT m.characteristic_id - FROM measurements m - INNER JOIN strains st ON st.id=m.strain_id - INNER JOIN species sp ON sp.id=st.species_id - INNER JOIN genera g ON g.id=sp.genus_id AND LOWER(g.genus_name)=LOWER($1);` - if err := DBH.Select(&relatedCharacteristicsIds, q, opt.Genus); err != nil { - return nil, err - } - } else { - var vals []interface{} - var count int64 = 1 - q := fmt.Sprintf("SELECT DISTINCT characteristic_id FROM measurements WHERE %s;", valsIn("strain_id", opt.Ids, &vals, &count)) - if err := DBH.Select(&relatedCharacteristicsIds, q, vals...); err != nil { - return nil, err - } - } - - return &ListOptions{Genus: opt.Genus, Ids: relatedCharacteristicsIds}, nil -} diff --git a/types.go b/types.go deleted file mode 100644 index 7e022eb..0000000 --- a/types.go +++ /dev/null @@ -1,211 +0,0 @@ -package main - -import ( - "bytes" - "database/sql" - "encoding/json" - "errors" - "strconv" - "strings" - "time" - - "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/lib/pq" -) - -type NullBool struct { - sql.NullBool -} - -func (b *NullBool) MarshalJSON() ([]byte, error) { - if !b.Valid { - return []byte("null"), nil - } - return json.Marshal(b.Bool) -} - -func (n *NullBool) UnmarshalJSON(b []byte) error { - if bytes.Equal(b, []byte("null")) { - n.Bool = false - n.Valid = false - return nil - } - var x interface{} - var err error - json.Unmarshal(b, &x) - switch x.(type) { - case bool: - err = json.Unmarshal(b, &n.Bool) - case map[string]interface{}: - err = json.Unmarshal(b, &n.NullBool) - } - n.Valid = true - return err -} - -type NullString struct { - sql.NullString -} - -func (s *NullString) MarshalJSON() ([]byte, error) { - if !s.Valid { - return []byte("null"), nil - } - return json.Marshal(s.String) -} - -func (s *NullString) UnmarshalJSON(b []byte) error { - if bytes.Equal(b, []byte("null")) { - s.String = "" - s.Valid = false - return nil - } - var x interface{} - var err error - json.Unmarshal(b, &x) - switch x.(type) { - case string: - err = json.Unmarshal(b, &s.String) - case map[string]interface{}: - err = json.Unmarshal(b, &s.NullString) - } - s.Valid = true - return err -} - -type NullInt64 struct { - sql.NullInt64 -} - -func (i *NullInt64) MarshalJSON() ([]byte, error) { - if !i.Valid { - return []byte("null"), nil - } - return json.Marshal(i.Int64) -} - -func (i *NullInt64) UnmarshalJSON(b []byte) error { - if bytes.Equal(b, []byte("null")) { - i.Int64 = 0 - i.Valid = false - return nil - } - var x interface{} - var err error - json.Unmarshal(b, &x) - switch x.(type) { - case float64: - err = json.Unmarshal(b, &i.Int64) - case map[string]interface{}: - err = json.Unmarshal(b, &i.NullInt64) - } - i.Valid = true - return err -} - -type NullFloat64 struct { - sql.NullFloat64 -} - -func (f *NullFloat64) MarshalJSON() ([]byte, error) { - if !f.Valid { - return []byte("null"), nil - } - return json.Marshal(f.Float64) -} - -func (f *NullFloat64) UnmarshalJSON(b []byte) error { - if bytes.Equal(b, []byte("null")) { - f.Float64 = 0 - f.Valid = false - return nil - } - var x interface{} - var err error - json.Unmarshal(b, &x) - switch x.(type) { - case float64: - err = json.Unmarshal(b, &f.Float64) - case map[string]interface{}: - err = json.Unmarshal(b, &f.NullFloat64) - } - f.Valid = true - return err -} - -type NullTime struct { - pq.NullTime -} - -func (t *NullTime) MarshalJSON() ([]byte, error) { - if !t.Valid { - return []byte("null"), nil - } - return json.Marshal(t.Time) -} - -func (t *NullTime) UnmarshalJSON(b []byte) error { - if bytes.Equal(b, []byte("null")) { - var nt time.Time - t.Time = nt.In(time.UTC) - t.Valid = false - return nil - } - var x interface{} - var err error - json.Unmarshal(b, &x) - switch x.(type) { - case string: - err = json.Unmarshal(b, &t.Time) - } - t.Valid = true - return err -} - -type NullSliceInt64 []int64 - -func (i *NullSliceInt64) Scan(src interface{}) error { - asBytes, ok := src.([]byte) - if !ok { - return errors.New("Scan source was not []byte") - } - asString := string(asBytes) - (*i) = strToIntSlice(asString) - return nil -} - -func strToIntSlice(s string) []int64 { - r := strings.Trim(s, "{}") - a := []int64(nil) - for _, t := range strings.Split(r, ",") { - if t != "NULL" { - i, _ := strconv.ParseInt(t, 10, 64) - a = append(a, i) - } - } - return a -} - -type ErrorJSON struct { - Err error -} - -func (ej ErrorJSON) Error() string { - e, _ := json.Marshal(struct { - Err string `json:"error"` - }{ - Err: ej.Err.Error(), - }) - return string(e) -} - -type appError struct { - Error error - Status int -} - -func newJSONError(err error, status int) *appError { - return &appError{ - Error: ErrorJSON{Err: err}, - Status: status, - } -} diff --git a/types/claims.go b/types/claims.go new file mode 100644 index 0000000..0a249fb --- /dev/null +++ b/types/claims.go @@ -0,0 +1,12 @@ +package types + +// Claims represent an authenticated user's session. +type Claims struct { + Name string + Iss string + Sub int64 + Role string + Iat int64 + Exp int64 + Ref string +} diff --git a/types/entity.go b/types/entity.go new file mode 100644 index 0000000..033d89d --- /dev/null +++ b/types/entity.go @@ -0,0 +1,6 @@ +package types + +// Entity is a a payload or model. +type Entity interface { + Marshal() ([]byte, error) +} diff --git a/types/error_json.go b/types/error_json.go new file mode 100644 index 0000000..aebf203 --- /dev/null +++ b/types/error_json.go @@ -0,0 +1,25 @@ +package types + +import "encoding/json" + +// ErrorJSON is an error that serializes to a JSON-encoded representation of the +// error message. +type ErrorJSON struct { + Err error +} + +// Error satisfies the necessary interface to make ErrorJSON an error. +func (ej ErrorJSON) Error() string { + e, _ := json.Marshal(struct { + Err string `json:"error"` + }{ + Err: ej.Err.Error(), + }) + return string(e) +} + +// AppError returns an error plus an HTTP status code. +type AppError struct { + Error error + Status int +} diff --git a/types/null_bool.go b/types/null_bool.go new file mode 100644 index 0000000..7f77ab4 --- /dev/null +++ b/types/null_bool.go @@ -0,0 +1,40 @@ +package types + +import ( + "bytes" + "database/sql" + "encoding/json" +) + +// NullBool wraps sql.NullBool so that the JSON serialization can be overridden. +type NullBool struct { + sql.NullBool +} + +// MarshalJSON makes NullBool a json.Marshaller. +func (n *NullBool) MarshalJSON() ([]byte, error) { + if !n.Valid { + return []byte("null"), nil + } + return json.Marshal(n.Bool) +} + +// UnmarshalJSON makes NullBool a json.Unmarshaller. +func (n *NullBool) UnmarshalJSON(b []byte) error { + if bytes.Equal(b, []byte("null")) { + n.Bool = false + n.Valid = false + return nil + } + var x interface{} + var err error + json.Unmarshal(b, &x) + switch x.(type) { + case bool: + err = json.Unmarshal(b, &n.Bool) + case map[string]interface{}: + err = json.Unmarshal(b, &n.NullBool) + } + n.Valid = true + return err +} diff --git a/types/null_float64.go b/types/null_float64.go new file mode 100644 index 0000000..2f3772a --- /dev/null +++ b/types/null_float64.go @@ -0,0 +1,40 @@ +package types + +import ( + "bytes" + "database/sql" + "encoding/json" +) + +// NullFloat64 wraps sql.NullBool so that the JSON serialization can be overridden. +type NullFloat64 struct { + sql.NullFloat64 +} + +// MarshalJSON makes NullFloat64 a json.Marshaller. +func (f *NullFloat64) MarshalJSON() ([]byte, error) { + if !f.Valid { + return []byte("null"), nil + } + return json.Marshal(f.Float64) +} + +// UnmarshalJSON makes NullFloat64 a json.Unmarshaller. +func (f *NullFloat64) UnmarshalJSON(b []byte) error { + if bytes.Equal(b, []byte("null")) { + f.Float64 = 0 + f.Valid = false + return nil + } + var x interface{} + var err error + json.Unmarshal(b, &x) + switch x.(type) { + case float64: + err = json.Unmarshal(b, &f.Float64) + case map[string]interface{}: + err = json.Unmarshal(b, &f.NullFloat64) + } + f.Valid = true + return err +} diff --git a/types/null_int64.go b/types/null_int64.go new file mode 100644 index 0000000..34874d2 --- /dev/null +++ b/types/null_int64.go @@ -0,0 +1,40 @@ +package types + +import ( + "bytes" + "database/sql" + "encoding/json" +) + +//NullInt64 wraps sql.NullInt64 so that the JSON serialization can be overridden. +type NullInt64 struct { + sql.NullInt64 +} + +// MarshalJSON makes NullInt64 a json.Marshaller. +func (i *NullInt64) MarshalJSON() ([]byte, error) { + if !i.Valid { + return []byte("null"), nil + } + return json.Marshal(i.Int64) +} + +// UnmarshalJSON makes NullInt64 a json.Unmarshaller. +func (i *NullInt64) UnmarshalJSON(b []byte) error { + if bytes.Equal(b, []byte("null")) { + i.Int64 = 0 + i.Valid = false + return nil + } + var x interface{} + var err error + json.Unmarshal(b, &x) + switch x.(type) { + case float64: + err = json.Unmarshal(b, &i.Int64) + case map[string]interface{}: + err = json.Unmarshal(b, &i.NullInt64) + } + i.Valid = true + return err +} diff --git a/types/null_slice_int64.go b/types/null_slice_int64.go new file mode 100644 index 0000000..81d6860 --- /dev/null +++ b/types/null_slice_int64.go @@ -0,0 +1,34 @@ +package types + +import ( + "strconv" + "strings" + + "github.com/thermokarst/bactdb/errors" +) + +// NullSliceInt64 allows bactdb to read Postgres array types. +type NullSliceInt64 []int64 + +// Scan makes NullSliceInt64 a sql.Scanner. +func (i *NullSliceInt64) Scan(src interface{}) error { + asBytes, ok := src.([]byte) + if !ok { + return errors.ErrSourceNotByteSlice + } + asString := string(asBytes) + (*i) = strToIntSlice(asString) + return nil +} + +func strToIntSlice(s string) []int64 { + r := strings.Trim(s, "{}") + a := []int64(nil) + for _, t := range strings.Split(r, ",") { + if t != "NULL" { + i, _ := strconv.ParseInt(t, 10, 64) + a = append(a, i) + } + } + return a +} diff --git a/types/null_string.go b/types/null_string.go new file mode 100644 index 0000000..0467ebb --- /dev/null +++ b/types/null_string.go @@ -0,0 +1,40 @@ +package types + +import ( + "bytes" + "database/sql" + "encoding/json" +) + +// NullString wraps sql.NullString so that the JSON serialization can be overridden. +type NullString struct { + sql.NullString +} + +// MarshalJSON makes NullString a json.Marshaller. +func (s *NullString) MarshalJSON() ([]byte, error) { + if !s.Valid { + return []byte("null"), nil + } + return json.Marshal(s.String) +} + +// UnmarshalJSON makes NullString a json.Unmarshaller. +func (s *NullString) UnmarshalJSON(b []byte) error { + if bytes.Equal(b, []byte("null")) { + s.String = "" + s.Valid = false + return nil + } + var x interface{} + var err error + json.Unmarshal(b, &x) + switch x.(type) { + case string: + err = json.Unmarshal(b, &s.String) + case map[string]interface{}: + err = json.Unmarshal(b, &s.NullString) + } + s.Valid = true + return err +} diff --git a/types/null_time.go b/types/null_time.go new file mode 100644 index 0000000..76bfbba --- /dev/null +++ b/types/null_time.go @@ -0,0 +1,41 @@ +package types + +import ( + "bytes" + "encoding/json" + "time" + + "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/lib/pq" +) + +// NullTime wraps pq.NullTime so that the JSON serialization can be overridden. +type NullTime struct { + pq.NullTime +} + +// MarshalJSON makes NullTime a json.Marshaller. +func (t *NullTime) MarshalJSON() ([]byte, error) { + if !t.Valid { + return []byte("null"), nil + } + return json.Marshal(t.Time) +} + +// UnmarshalJSON makes NullTime a json.Unmarshaller. +func (t *NullTime) UnmarshalJSON(b []byte) error { + if bytes.Equal(b, []byte("null")) { + var nt time.Time + t.Time = nt.In(time.UTC) + t.Valid = false + return nil + } + var x interface{} + var err error + json.Unmarshal(b, &x) + switch x.(type) { + case string: + err = json.Unmarshal(b, &t.Time) + } + t.Valid = true + return err +} diff --git a/users.go b/users.go deleted file mode 100644 index 3f1fd22..0000000 --- a/users.go +++ /dev/null @@ -1,355 +0,0 @@ -package main - -import ( - "database/sql" - "encoding/json" - "errors" - "fmt" - "log" - "net/http" - "net/url" - "regexp" - - "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/gorilla/mux" - "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/lib/pq" - "github.com/thermokarst/bactdb/Godeps/_workspace/src/github.com/mailgun/mailgun-go" - "github.com/thermokarst/bactdb/Godeps/_workspace/src/golang.org/x/crypto/bcrypt" -) - -var ( - ErrUserNotFound = errors.New("User not found") - ErrUserNotFoundJSON = newJSONError(ErrUserNotFound, http.StatusNotFound) - ErrUserNotUpdated = errors.New("User not updated") - ErrUserNotUpdatedJSON = newJSONError(ErrUserNotUpdated, http.StatusBadRequest) - ErrInvalidEmailOrPassword = errors.New("Invalid email or password") - ErrEmailAddressTaken = errors.New("Email address already registered") - ErrEmailAddressTakenJSON = newJSONError(ErrEmailAddressTaken, http.StatusBadRequest) -) - -func init() { - DB.AddTableWithName(User{}, "users").SetKeys(true, "Id") -} - -type UserService struct{} - -type User struct { - Id int64 `json:"id,omitempty"` - Email string `db:"email" json:"email"` - Password string `db:"password" json:"password,omitempty"` - Name string `db:"name" json:"name"` - Role string `db:"role" json:"role"` - Verified bool `db:"verified" json:"-"` - CreatedAt NullTime `db:"created_at" json:"createdAt"` - UpdatedAt NullTime `db:"updated_at" json:"updatedAt"` - DeletedAt NullTime `db:"deleted_at" json:"deletedAt"` -} - -type UserValidation struct { - Email []string `json:"email,omitempty"` - Password []string `json:"password,omitempty"` - Name []string `json:"name,omitempty"` - Role []string `json:"role,omitempty"` -} - -func (uv UserValidation) Error() string { - errs, err := json.Marshal(struct { - UserValidation `json:"errors"` - }{uv}) - if err != nil { - return err.Error() - } - return string(errs) -} - -type Users []*User - -type UserJSON struct { - User *User `json:"user"` -} - -type UsersJSON struct { - Users *Users `json:"users"` -} - -func (u *User) marshal() ([]byte, error) { - return json.Marshal(&UserJSON{User: u}) -} - -func (u *Users) marshal() ([]byte, error) { - return json.Marshal(&UsersJSON{Users: u}) -} - -func (u UserService) unmarshal(b []byte) (entity, error) { - var uj UserJSON - err := json.Unmarshal(b, &uj) - return uj.User, err -} - -func (u *User) validate() error { - var uv UserValidation - validationError := false - - if u.Name == "" { - uv.Name = append(uv.Name, MustProvideAValue) - validationError = true - } - - if u.Email == "" { - uv.Email = append(uv.Email, MustProvideAValue) - validationError = true - } - - regex, _ := regexp.Compile(`(\w[-._\w]*\w@\w[-._\w]*\w\.\w{2,3})`) - if u.Email != "" && !regex.MatchString(u.Email) { - uv.Email = append(uv.Email, "Must provide a valid email address") - validationError = true - } - - if len(u.Password) < 8 { - uv.Password = append(uv.Password, "Password must be at least 8 characters") - validationError = true - } - - if validationError { - return uv - } - return nil -} - -func (u UserService) list(val *url.Values, claims *Claims) (entity, *appError) { - if val == nil { - return nil, ErrMustProvideOptionsJSON - } - var opt ListOptions - if err := schemaDecoder.Decode(&opt, *val); err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - - users := make(Users, 0) - sql := `SELECT id, email, 'password' AS password, name, role, - created_at, updated_at, deleted_at - FROM users - WHERE verified IS TRUE - AND deleted_at IS NULL;` - if err := DBH.Select(&users, sql); err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - return &users, nil -} - -func (u UserService) get(id int64, dummy string, claims *Claims) (entity, *appError) { - user, err := dbGetUserById(id) - if err != nil { - return nil, newJSONError(err, http.StatusInternalServerError) - } - return user, nil -} - -func (u UserService) update(id int64, e *entity, dummy string, claims *Claims) *appError { - user := (*e).(*User) - user.UpdatedAt = currentTime() - user.Id = id - - count, err := DBH.Update(user) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - if count != 1 { - return ErrUserNotUpdatedJSON - } - return nil -} - -func (u UserService) create(e *entity, dummy string, claims *Claims) *appError { - user := (*e).(*User) - if err := user.validate(); err != nil { - return &appError{Error: err, Status: StatusUnprocessableEntity} - } - ct := currentTime() - user.CreatedAt = ct - user.UpdatedAt = ct - hash, err := bcrypt.GenerateFromPassword([]byte(user.Password), 12) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - user.Password = string(hash) - user.Role = "R" - user.Verified = false - - if err := DBH.Insert(user); err != nil { - if err, ok := err.(*pq.Error); ok { - if err.Code == "23505" { - return ErrEmailAddressTakenJSON - } - } - return newJSONError(err, http.StatusInternalServerError) - } - - user.Password = "password" // don't want to send the hashed PW back to the client - - q := `INSERT INTO verification (user_id, nonce, referer, created_at) VALUES ($1, $2, $3, $4);` - nonce, err := generateNonce() - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - _, err = DBH.Exec(q, user.Id, nonce, claims.Ref, ct) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - - // Send out confirmation email - mg, ok := mgAccts[claims.Ref] - if ok { - sender := fmt.Sprintf("%s Admin ", mg.Domain(), mg.Domain()) - recipient := fmt.Sprintf("%s <%s>", user.Name, user.Email) - subject := fmt.Sprintf("New Account Confirmation - %s", mg.Domain()) - message := fmt.Sprintf("You are receiving this message because this email "+ - "address was used to sign up for an account at %s. Please visit this "+ - "URL to complete the sign up process: %s/users/new/verify/%s. If you "+ - "did not request an account, please disregard this message.", - mg.Domain(), claims.Ref, nonce) - m := mailgun.NewMessage(sender, subject, message, recipient) - _, _, err := mg.Send(m) - if err != nil { - log.Printf("%+v\n", err) - return newJSONError(err, http.StatusInternalServerError) - } - } - - return nil -} - -// for thermokarst/jwt: authentication callback -func dbAuthenticate(email string, password string) error { - var user User - q := `SELECT * - FROM users - WHERE lower(email)=lower($1) - AND verified IS TRUE - AND deleted_at IS NULL;` - if err := DBH.SelectOne(&user, q, email); err != nil { - return ErrInvalidEmailOrPassword - } - if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil { - return ErrInvalidEmailOrPassword - } - return nil -} - -func dbGetUserById(id int64) (*User, error) { - var user User - q := `SELECT id, email, 'password' AS password, name, role, - created_at, updated_at, deleted_at - FROM users - WHERE id=$1 - AND verified IS TRUE - AND deleted_at IS NULL;` - if err := DBH.SelectOne(&user, q, id); err != nil { - if err == sql.ErrNoRows { - return nil, ErrUserNotFound - } - return nil, err - } - return &user, nil -} - -// for thermokarst/jwt: setting user in claims bundle -func dbGetUserByEmail(email string) (*User, error) { - var user User - q := `SELECT * - FROM users - WHERE lower(email)=lower($1) - AND verified IS TRUE - AND deleted_at IS NULL;` - if err := DBH.SelectOne(&user, q, email); err != nil { - if err == sql.ErrNoRows { - return nil, ErrUserNotFound - } - return nil, err - } - return &user, nil -} - -func handleUserVerify(w http.ResponseWriter, r *http.Request) *appError { - nonce := mux.Vars(r)["Nonce"] - q := `SELECT user_id, referer FROM verification WHERE nonce=$1;` - - var ver struct { - User_id int64 - Referer string - } - if err := DBH.SelectOne(&ver, q, nonce); err != nil { - log.Print(err) - return newJSONError(err, http.StatusInternalServerError) - } - - if ver.User_id == 0 { - return newJSONError(errors.New("No user found"), http.StatusInternalServerError) - } - - var user User - if err := DBH.Get(&user, ver.User_id); err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - - user.UpdatedAt = currentTime() - user.Verified = true - - count, err := DBH.Update(&user) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - if count != 1 { - return newJSONError(errors.New("Count 0"), http.StatusInternalServerError) - } - - q = `DELETE FROM verification WHERE user_id=$1;` - _, err = DBH.Exec(q, user.Id) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - fmt.Fprintln(w, `{"msg":"All set! Please log in."}`) - return nil -} - -func handleUserLockout(w http.ResponseWriter, r *http.Request) *appError { - email := r.FormValue("email") - if email == "" { - return newJSONError(errors.New("missing email"), http.StatusInternalServerError) - } - token, err := j.CreateToken(email) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - origin := r.Header.Get("Origin") - hostUrl, err := url.Parse(origin) - if err != nil { - return newJSONError(err, http.StatusInternalServerError) - } - hostUrl.Path += "/users/lockoutauthenticate" - params := url.Values{} - params.Add("token", token) - hostUrl.RawQuery = params.Encode() - - // Send out email - mg, ok := mgAccts[origin] - if ok { - sender := fmt.Sprintf("%s Admin ", mg.Domain(), mg.Domain()) - recipient := fmt.Sprintf("%s", email) - subject := fmt.Sprintf("Password Reset Request - %s", mg.Domain()) - message := fmt.Sprintf("You are receiving this message because this email "+ - "address was used in an account lockout request at %s. Please visit "+ - "this URL to complete the process: %s. If you did not request help "+ - "with a lockout, please disregard this message.", - mg.Domain(), hostUrl.String()) - m := mailgun.NewMessage(sender, subject, message, recipient) - _, _, err := mg.Send(m) - if err != nil { - log.Printf("%+v\n", err) - return newJSONError(err, http.StatusInternalServerError) - } - } - - fmt.Fprintln(w, `{}`) - return nil -}