diff --git a/controller/controller_test.go b/controller/controller_test.go index 7600095..738e819 100644 --- a/controller/controller_test.go +++ b/controller/controller_test.go @@ -16,6 +16,7 @@ import ( "github.com/tarkov-database/rest-api/model/location" "github.com/tarkov-database/rest-api/model/location/feature" "github.com/tarkov-database/rest-api/model/location/featuregroup" + "github.com/tarkov-database/rest-api/model/statistic/ammunition/armor" "github.com/tarkov-database/rest-api/model/user" "github.com/google/logger" @@ -26,13 +27,14 @@ import ( const contentTypeJSON = "application/json" var ( - itemIDs []primitive.ObjectID - userIDs []primitive.ObjectID - moduleIDs []primitive.ObjectID - productionIDs []primitive.ObjectID - locationIDs []primitive.ObjectID - featureIDs []primitive.ObjectID - featureGroupIDs []primitive.ObjectID + itemIDs []primitive.ObjectID + userIDs []primitive.ObjectID + moduleIDs []primitive.ObjectID + productionIDs []primitive.ObjectID + locationIDs []primitive.ObjectID + featureIDs []primitive.ObjectID + featureGroupIDs []primitive.ObjectID + ammoArmorStatsIDs []primitive.ObjectID ) func init() { @@ -50,6 +52,7 @@ func mongoStartup() { createProductions() createLocations() createFeatureGroups() + createStatisticAmmoArmor() createFeatures() } @@ -60,6 +63,7 @@ func mongoCleanup() { removeLocations() removeFeatures() removeFeatureGroups() + removeStatisticAmmoArmor() removeUsers() if err := database.Shutdown(); err != nil { @@ -175,6 +179,24 @@ func removeFeatureGroupID(id primitive.ObjectID) { featureGroupIDs = new } +func createStatisticAmmoArmorID() primitive.ObjectID { + id := primitive.NewObjectID() + ammoArmorStatsIDs = append(ammoArmorStatsIDs, id) + + return id +} + +func removeStatisticAmmoArmorID(id primitive.ObjectID) { + new := make([]primitive.ObjectID, 0, len(ammoArmorStatsIDs)-1) + for _, k := range ammoArmorStatsIDs { + if k != id { + new = append(new, k) + } + } + + ammoArmorStatsIDs = new +} + func createUserID() primitive.ObjectID { id := primitive.NewObjectID() userIDs = append(userIDs, id) @@ -287,7 +309,7 @@ func removeProductions() { ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) defer cancel() - if _, err := c.DeleteMany(ctx, bson.M{"_id": bson.M{"$in": moduleIDs}}); err != nil { + if _, err := c.DeleteMany(ctx, bson.M{"_id": bson.M{"$in": productionIDs}}); err != nil { log.Fatalf("Database cleanup error: %s", err) } } @@ -419,6 +441,55 @@ func removeFeatureGroups() { } } +func createStatisticAmmoArmor() { + c := database.GetDB().Collection(armor.Collection) + + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + + statsA := armor.AmmoArmorStatistics{ + ID: createStatisticAmmoArmorID(), + Ammo: primitive.NewObjectID(), + Armor: armor.ItemRef{ + ID: primitive.NewObjectID(), + Kind: item.KindTacticalrig, + }, + Distance: 100, + PenetrationChance: [4]float64{}, + AverageShotsToDestruction: armor.Statistics{}, + AverageShotsTo50Damage: armor.Statistics{}, + Modified: model.Timestamp{Time: time.Now()}, + } + statsB := armor.AmmoArmorStatistics{ + ID: createStatisticAmmoArmorID(), + Ammo: primitive.NewObjectID(), + Armor: armor.ItemRef{ + ID: primitive.NewObjectID(), + Kind: item.KindArmor, + }, + Distance: 500, + PenetrationChance: [4]float64{}, + AverageShotsToDestruction: armor.Statistics{}, + AverageShotsTo50Damage: armor.Statistics{}, + Modified: model.Timestamp{Time: time.Now()}, + } + + if _, err := c.InsertMany(ctx, bson.A{statsA, statsB}); err != nil { + log.Fatalf("Database startup error: %s", err) + } +} + +func removeStatisticAmmoArmor() { + c := database.GetDB().Collection(armor.Collection) + + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + + if _, err := c.DeleteMany(ctx, bson.M{"_id": bson.M{"$in": ammoArmorStatsIDs}}); err != nil { + log.Fatalf("Database cleanup error: %s", err) + } +} + func createUsers() { c := database.GetDB().Collection(user.Collection) diff --git a/controller/helper.go b/controller/helper.go index 7c27638..bf66519 100644 --- a/controller/helper.go +++ b/controller/helper.go @@ -2,8 +2,10 @@ package controller import ( "encoding/json" + "fmt" "io" "net/http" + "net/url" "regexp" "strconv" "strings" @@ -85,3 +87,21 @@ func parseJSONBody(body io.ReadCloser, target interface{}) error { defer body.Close() return json.NewDecoder(body).Decode(target) } + +func parseObjIDs(query string) ([]string, error) { + q, err := url.QueryUnescape(query) + if err != nil { + return nil, fmt.Errorf("Query string error: %s", err) + } + + if len(q) < 24 { + return nil, fmt.Errorf("ID is not valid") + } + + ids := strings.Split(q, ",") + if len(ids) > 100 { + return nil, fmt.Errorf("ID limit exceeded") + } + + return ids, nil +} diff --git a/controller/statistics.go b/controller/statistics.go new file mode 100644 index 0000000..ed0125a --- /dev/null +++ b/controller/statistics.go @@ -0,0 +1,346 @@ +package controller + +import ( + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/tarkov-database/rest-api/model" + "github.com/tarkov-database/rest-api/model/statistic/ammunition/armor" + "github.com/tarkov-database/rest-api/model/statistic/ammunition/distance" + "github.com/tarkov-database/rest-api/view" + + "github.com/google/logger" + "github.com/julienschmidt/httprouter" +) + +// DistanceStatGET handles a GET request on a ammunition statistics distance entity endpoint +func DistanceStatGET(w http.ResponseWriter, _ *http.Request, ps httprouter.Params) { + loc, err := distance.GetByID(ps.ByName("id")) + if err != nil { + handleError(err, w) + return + } + + view.RenderJSON(loc, http.StatusOK, w) +} + +// DistanceStatsGET handles a GET request on the distance root endpoint +func DistanceStatsGET(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + var result *model.Result + var err error + + opts := &distance.Options{Sort: getSort("distance", r)} + opts.Limit, opts.Offset = getLimitOffset(r) + + var gte, lte *uint64 + if v := r.URL.Query().Get("range"); v != "" { + v, err := url.QueryUnescape(v) + if err != nil { + StatusBadRequest("query value of \"range\" has an invalid value").Render(w) + return + } + + a := strings.SplitN(v, ",", 2) + + left, err := strconv.ParseUint(a[0], 10, 64) + if err != nil { + StatusBadRequest("query value of \"range\" has an invalid type").Render(w) + return + } + + gte = &left + + if len(a) > 1 { + right, err := strconv.ParseUint(a[1], 10, 64) + if err != nil { + StatusBadRequest("query value of \"range\" has an invalid type").Render(w) + return + } + + lte = &right + } + } + + var ids []string + if v := r.URL.Query().Get("ammo"); v != "" { + v, err := parseObjIDs(v) + if err != nil { + StatusBadRequest(fmt.Sprintf("query value of \"ammo\" is invalid: %s", err)).Render(w) + return + } + ids = v + } + + result, err = distance.GetByRefsAndRange(ids, gte, lte, opts) + if err != nil { + handleError(err, w) + return + } + + view.RenderJSON(result, http.StatusOK, w) +} + +// DistanceStatPOST handles a POST request on the distance root endpoint +func DistanceStatPOST(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + if !isSupportedMediaType(r) { + StatusUnsupportedMediaType("Wrong content type").Render(w) + return + } + + stat := &distance.AmmoDistanceStatistics{} + + if err := parseJSONBody(r.Body, stat); err != nil { + StatusBadRequest(fmt.Sprintf("JSON parsing error: %s", err)).Render(w) + return + } + + if err := stat.Validate(); err != nil { + StatusUnprocessableEntity(fmt.Sprintf("Validation error: %s", err)).Render(w) + return + } + + result, err := distance.GetByRefsAndRange([]string{stat.Reference.Hex()}, + &stat.Distance, &stat.Distance, &distance.Options{}) + if err != nil { + handleError(err, w) + return + } + + if result.Count != 0 { + StatusBadRequest("entity already exists").Render(w) + } + + if err := distance.Create(stat); err != nil { + handleError(err, w) + return + } + + logger.Infof("Distance statistics %s created", stat.ID.Hex()) + + view.RenderJSON(stat, http.StatusCreated, w) +} + +// DistanceStatPUT handles a PUT request on a distance entity endpoint +func DistanceStatPUT(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + if !isSupportedMediaType(r) { + StatusUnsupportedMediaType("Wrong content type").Render(w) + return + } + + stat := &distance.AmmoDistanceStatistics{} + + if err := parseJSONBody(r.Body, stat); err != nil { + StatusBadRequest(fmt.Sprintf("JSON parsing error: %s", err)).Render(w) + return + } + + if err := stat.Validate(); err != nil { + StatusUnprocessableEntity(fmt.Sprintf("Validation error: %s", err)).Render(w) + return + } + + id := ps.ByName("id") + + if !stat.ID.IsZero() && stat.ID.Hex() != id { + StatusUnprocessableEntity("ID mismatch").Render(w) + return + } + + if err := distance.Replace(id, stat); err != nil { + handleError(err, w) + return + } + + logger.Infof("Distance statistics %s updated", stat.ID.Hex()) + + view.RenderJSON(stat, http.StatusOK, w) +} + +// DistanceStatDELETE handles a DELETE request on a distance entity endpoint +func DistanceStatDELETE(w http.ResponseWriter, _ *http.Request, ps httprouter.Params) { + id := ps.ByName("id") + + if err := distance.Remove(id); err != nil { + handleError(err, w) + return + } + + logger.Infof("Distance statistics %s removed", id) + + w.WriteHeader(http.StatusNoContent) +} + +// ArmorStatGET handles a GET request on a ammunition statistics armor entity endpoint +func ArmorStatGET(w http.ResponseWriter, _ *http.Request, ps httprouter.Params) { + loc, err := armor.GetByID(ps.ByName("id")) + if err != nil { + handleError(err, w) + return + } + + view.RenderJSON(loc, http.StatusOK, w) +} + +// ArmorStatsGET handles a GET request on the distance root endpoint +func ArmorStatsGET(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + var result *model.Result + var err error + + opts := &armor.Options{Sort: getSort("distance", r)} + opts.Limit, opts.Offset = getLimitOffset(r) + + rangeOpts := &armor.RangeOptions{} + if v := r.URL.Query().Get("range"); v != "" { + v, err := url.QueryUnescape(v) + if err != nil { + StatusBadRequest("query value of \"range\" has an invalid value").Render(w) + return + } + + a := strings.SplitN(v, ",", 2) + + gte, err := strconv.ParseUint(a[0], 10, 64) + if err != nil { + StatusBadRequest("query value of \"range\" has an invalid type").Render(w) + return + } + + rangeOpts.GTE = >e + + if len(a) > 1 { + lte, err := strconv.ParseUint(a[1], 10, 64) + if err != nil { + StatusBadRequest("query value of \"range\" has an invalid type").Render(w) + return + } + + rangeOpts.LTE = <e + } + } + + var ammoIDs []string + if v := r.URL.Query().Get("ammo"); v != "" { + v, err := parseObjIDs(v) + if err != nil { + StatusBadRequest(fmt.Sprintf("query value of \"ammo\" is invalid: %s", err)).Render(w) + return + } + ammoIDs = v + } + + var armorIDs []string + if v := r.URL.Query().Get("armor"); v != "" { + v, err := parseObjIDs(v) + if err != nil { + StatusBadRequest(fmt.Sprintf("query value of \"armor\" is invalid: %s", err)).Render(w) + return + } + armorIDs = v + } + + result, err = armor.GetByRefs(ammoIDs, armorIDs, rangeOpts, opts) + if err != nil { + handleError(err, w) + return + } + + view.RenderJSON(result, http.StatusOK, w) +} + +// ArmorStatPOST handles a POST request on the armor root endpoint +func ArmorStatPOST(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + if !isSupportedMediaType(r) { + StatusUnsupportedMediaType("Wrong content type").Render(w) + return + } + + stat := &armor.AmmoArmorStatistics{} + + if err := parseJSONBody(r.Body, stat); err != nil { + StatusBadRequest(fmt.Sprintf("JSON parsing error: %s", err)).Render(w) + return + } + + if err := stat.Validate(); err != nil { + StatusUnprocessableEntity(fmt.Sprintf("Validation error: %s", err)).Render(w) + return + } + + rangeOpts := &armor.RangeOptions{ + GTE: &stat.Distance, + LTE: &stat.Distance, + } + + result, err := armor.GetByRefs([]string{stat.Ammo.Hex()}, []string{stat.Armor.ID.Hex()}, + rangeOpts, &armor.Options{}) + if err != nil { + handleError(err, w) + return + } + + if result.Count != 0 { + StatusBadRequest("entity already exists").Render(w) + } + + if err := armor.Create(stat); err != nil { + handleError(err, w) + return + } + + logger.Infof("Armor statistics %s created", stat.ID.Hex()) + + view.RenderJSON(stat, http.StatusCreated, w) +} + +// ArmorStatPUT handles a PUT request on a armor entity endpoint +func ArmorStatPUT(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + if !isSupportedMediaType(r) { + StatusUnsupportedMediaType("Wrong content type").Render(w) + return + } + + stat := &armor.AmmoArmorStatistics{} + + if err := parseJSONBody(r.Body, stat); err != nil { + StatusBadRequest(fmt.Sprintf("JSON parsing error: %s", err)).Render(w) + return + } + + if err := stat.Validate(); err != nil { + StatusUnprocessableEntity(fmt.Sprintf("Validation error: %s", err)).Render(w) + return + } + + id := ps.ByName("id") + + if !stat.ID.IsZero() && stat.ID.Hex() != id { + StatusUnprocessableEntity("ID mismatch").Render(w) + return + } + + if err := armor.Replace(id, stat); err != nil { + handleError(err, w) + return + } + + logger.Infof("Armor statistics %s updated", stat.ID.Hex()) + + view.RenderJSON(stat, http.StatusOK, w) +} + +// ArmorStatDELETE handles a DELETE request on a armor entity endpoint +func ArmorStatDELETE(w http.ResponseWriter, _ *http.Request, ps httprouter.Params) { + id := ps.ByName("id") + + if err := armor.Remove(id); err != nil { + handleError(err, w) + return + } + + logger.Infof("Armor statistics %s removed", id) + + w.WriteHeader(http.StatusNoContent) +} diff --git a/controller/statistics_test.go b/controller/statistics_test.go new file mode 100644 index 0000000..d150eff --- /dev/null +++ b/controller/statistics_test.go @@ -0,0 +1,221 @@ +package controller + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/tarkov-database/rest-api/model" + "github.com/tarkov-database/rest-api/model/item" + "github.com/tarkov-database/rest-api/model/statistic/ammunition/armor" + "go.mongodb.org/mongo-driver/bson/primitive" + + "github.com/julienschmidt/httprouter" +) + +type ammoArmorStatsResult struct { + Count int64 `json:"total"` + Items []armor.AmmoArmorStatistics `json:"items"` +} + +func TestArmorStatsGET(t *testing.T) { + statID := ammoArmorStatsIDs[0] + + params := httprouter.Params{ + httprouter.Param{ + Key: "id", + Value: statID.Hex(), + }, + } + + w := httptest.NewRecorder() + + ArmorStatGET(w, &http.Request{}, params) + + resp := w.Result() + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("Getting ammo armor statistics failed: unexpcted response code %v", resp.StatusCode) + } + if resp.Header.Get("Content-Type") != contentTypeJSON { + t.Error("Getting ammo armor statistics failed: content type is invalid") + } + + output := &armor.AmmoArmorStatistics{} + + if err := json.NewDecoder(resp.Body).Decode(output); err != nil { + t.Fatalf("Getting ammo armor statistics failed: %s", err) + } + + if output.ID != statID { + t.Error("Getting ammo armor statistics failed: ammo armor statistics ID invalid") + } +} + +func TestArmorStatssGET(t *testing.T) { + req := httptest.NewRequest("GET", "http://example.com/v2/statistic/ammunition/armor", nil) + + w := httptest.NewRecorder() + + ArmorStatsGET(w, req, httprouter.Params{}) + + resp := w.Result() + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("Getting ammo armor statisticss failed: unexpcted response code %v", resp.StatusCode) + } + if resp.Header.Get("Content-Type") != contentTypeJSON { + t.Error("Getting ammo armor statisticss failed: content type is invalid") + } + + res := &ammoArmorStatsResult{} + + if err := json.NewDecoder(resp.Body).Decode(res); err != nil { + t.Fatalf("Getting ammo armor statisticss failed: %s", err) + } + + if res.Count < 1 { + t.Error("Getting ammo armor statisticss failed: result count invalid") + } + if len(res.Items) == 0 { + t.Fatal("Getting ammo armor statisticss failed: result empty") + } +} + +func TestArmorStatsPOST(t *testing.T) { + statID := createStatisticAmmoArmorID() + + buf := new(bytes.Buffer) + + input := &armor.AmmoArmorStatistics{ + ID: statID, + Ammo: primitive.NewObjectID(), + Armor: armor.ItemRef{ + ID: primitive.NewObjectID(), + Kind: item.KindArmor, + }, + Distance: 50, + PenetrationChance: [4]float64{}, + AverageShotsToDestruction: armor.Statistics{}, + AverageShotsTo50Damage: armor.Statistics{}, + Modified: model.Timestamp{Time: time.Now()}, + } + + if err := json.NewEncoder(buf).Encode(input); err != nil { + t.Fatalf("Creating ammo armor statistics failed: %s", err) + } + + req := httptest.NewRequest("POST", "http://example.com/v2/statistic/ammunition/armor", buf) + req.Header.Set("Content-Type", contentTypeJSON) + + w := httptest.NewRecorder() + + ArmorStatPOST(w, req, httprouter.Params{}) + + resp := w.Result() + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + t.Fatalf("Creating ammo armor statistics failed: unexpcted response code %v", resp.StatusCode) + } + if resp.Header.Get("Content-Type") != contentTypeJSON { + t.Error("Creating ammo armor statistics failed: content type is invalid") + } + + output := &armor.AmmoArmorStatistics{} + + if err := json.NewDecoder(resp.Body).Decode(output); err != nil { + t.Fatalf("Creating ammo armor statistics failed: %s", err) + } + + if output.ID != input.ID { + t.Errorf("Creating ammo armor statistics failed: ammo armor statistics ID %s and %s unequal", output.ID, input.ID) + } +} + +func TestArmorStatsPUT(t *testing.T) { + statID := ammoArmorStatsIDs[0] + + buf := new(bytes.Buffer) + + input := &armor.AmmoArmorStatistics{ + ID: statID, + Ammo: primitive.NewObjectID(), + Armor: armor.ItemRef{ + ID: primitive.NewObjectID(), + Kind: item.KindArmor, + }, + Distance: 10, + PenetrationChance: [4]float64{}, + AverageShotsToDestruction: armor.Statistics{}, + AverageShotsTo50Damage: armor.Statistics{}, + Modified: model.Timestamp{Time: time.Now()}, + } + + if err := json.NewEncoder(buf).Encode(input); err != nil { + t.Fatalf("Replacing ammo armor statistics failed: %s", err) + } + + req := httptest.NewRequest("PUT", "http://example.com/v2/statistic/ammunition/armor", buf) + req.Header.Set("Content-Type", contentTypeJSON) + + params := httprouter.Params{ + httprouter.Param{ + Key: "id", + Value: statID.Hex(), + }, + } + + w := httptest.NewRecorder() + + ArmorStatPUT(w, req, params) + + resp := w.Result() + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("Replacing ammo armor statistics failed: unexpcted response code %v", resp.StatusCode) + } + if resp.Header.Get("Content-Type") != contentTypeJSON { + t.Error("Replacing ammo armor statistics failed: content type is invalid") + } + + output := &armor.AmmoArmorStatistics{} + + if err := json.NewDecoder(resp.Body).Decode(output); err != nil { + t.Fatalf("Replacing ammo armor statistics failed: %s", err) + } + + if output.Distance != input.Distance { + t.Errorf("Replacing ammo armor statistics failed: distance %v and %v unequal", output.Distance, input.Distance) + } +} + +func TestArmorStatsDELETE(t *testing.T) { + statID := ammoArmorStatsIDs[len(ammoArmorStatsIDs)-1] + + params := httprouter.Params{ + httprouter.Param{ + Key: "id", + Value: statID.Hex(), + }, + } + + w := httptest.NewRecorder() + + ArmorStatDELETE(w, &http.Request{}, params) + + resp := w.Result() + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("Deleting ammo armor statistics failed: unexpcted response code %v", resp.StatusCode) + } + + removeStatisticAmmoArmorID(statID) +} diff --git a/middleware/jwt/jwt.go b/middleware/jwt/jwt.go index 95a288e..2c88224 100644 --- a/middleware/jwt/jwt.go +++ b/middleware/jwt/jwt.go @@ -70,6 +70,12 @@ const ( // ScopeLocationWrite represents the location write permission scope ScopeLocationWrite = "write:location" + // ScopeStatisticRead represents the statistic read permission scope + ScopeStatisticRead = "read:statistic" + + // ScopeStatisticWrite represents the statistic write permission scope + ScopeStatisticWrite = "write:statistic" + // ScopeUserRead represents the user read permission scope ScopeUserRead = "read:user" diff --git a/model/statistic/ammunition/armor/armor.go b/model/statistic/ammunition/armor/armor.go new file mode 100644 index 0000000..379beff --- /dev/null +++ b/model/statistic/ammunition/armor/armor.go @@ -0,0 +1,295 @@ +package armor + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/logger" + "github.com/tarkov-database/rest-api/core/database" + "github.com/tarkov-database/rest-api/model" + "github.com/tarkov-database/rest-api/model/item" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type objectID = model.ObjectID + +type timestamp = model.Timestamp + +// AmmoArmorStatistics describes the entity of a ammo against armor statistics +type AmmoArmorStatistics struct { + ID objectID `json:"_id" bson:"_id"` + Ammo objectID `json:"ammo" bson:"ammo"` + Armor ItemRef `json:"armor" bson:"armor"` + Distance uint64 `json:"distance" bson:"distance"` + PenetrationChance [4]float64 `json:"penetrationChance" bson:"penetrationChance"` + AverageShotsToDestruction Statistics `json:"avgShotsToDestruct" bson:"avgShotsToDestruct"` + AverageShotsTo50Damage Statistics `json:"avgShotsTo50Damage" bson:"avgShotsTo50Damage"` + Modified timestamp `json:"_modified" bson:"_modified"` +} + +// Validate validates the fields of a ArmorStatistics +func (d AmmoArmorStatistics) Validate() error { + if d.Ammo.IsZero() { + return errors.New("ammo id is missing") + } + if err := d.Armor.Validate(); err != nil { + return fmt.Errorf("armor reference is invalid: %w", err) + } + + return nil +} + +// ItemRef refers to an item entity +type ItemRef struct { + ID objectID `json:"id" bson:"id"` + Kind item.Kind `json:"kind" bson:"kind"` +} + +// Validate validates the fields of a ItemRef +func (d ItemRef) Validate() error { + if d.ID.IsZero() { + return errors.New("item reference id is missing") + } + if !d.Kind.IsValid() { + return errors.New("item reference kind is missing") + } + + return nil +} + +// Statistics describes the statistical values +type Statistics struct { + Min float64 `json:"min" bson:"min"` + Max float64 `json:"max" bson:"max"` + Mean float64 `json:"mean" bson:"mean"` + Median float64 `json:"median" bson:"median"` + StdDev float64 `json:"stdDev" bson:"stdDev"` +} + +// Collection indicates the MongoDB feature collection +const Collection = "statistics.ammunition.armor" + +func getOneByFilter(filter interface{}) (*AmmoArmorStatistics, error) { + c := database.GetDB().Collection(Collection) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + stats := &AmmoArmorStatistics{} + + if err := c.FindOne(ctx, filter).Decode(stats); err != nil { + if err != mongo.ErrNoDocuments { + logger.Error(err) + } + return stats, model.MongoToAPIError(err) + } + + return stats, nil +} + +// GetByID returns the entity of the given ID +func GetByID(id string) (*AmmoArmorStatistics, error) { + objID, err := model.ToObjectID(id) + if err != nil { + return &AmmoArmorStatistics{}, err + } + + return getOneByFilter(bson.M{"_id": objID}) +} + +// Options represents the options for a database operation +type Options struct { + Sort bson.D + Limit int64 + Offset int64 +} + +func getManyByFilter(filter interface{}, opts *Options) (*model.Result, error) { + c := database.GetDB().Collection(Collection) + + findOpts := options.Find() + findOpts.SetLimit(opts.Limit) + findOpts.SetSkip(opts.Offset) + findOpts.SetSort(opts.Sort) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var err error + + r := &model.Result{} + + r.Count, err = c.CountDocuments(ctx, filter) + if err != nil { + logger.Error(err) + return r, model.MongoToAPIError(err) + } + + if r.Count == 0 { + return r, nil + } + + cur, err := c.Find(ctx, filter, findOpts) + if err != nil { + if err != mongo.ErrNoDocuments { + logger.Error(err) + } + return r, model.MongoToAPIError(err) + } + + defer cur.Close(ctx) + + for cur.Next(ctx) { + stats := &AmmoArmorStatistics{} + + if err := cur.Decode(stats); err != nil { + logger.Error(err) + return r, model.MongoToAPIError(err) + } + + r.Items = append(r.Items, stats) + } + + if err := cur.Err(); err != nil { + return r, model.MongoToAPIError(err) + } + + return r, nil +} + +// RangeOptions represents the range options of a query +type RangeOptions struct { + GTE *uint64 + LTE *uint64 +} + +// GetAll returns a result based on filters +func GetAll(opts *Options) (*model.Result, error) { + return getManyByFilter(bson.D{}, opts) +} + +// GetByRefs returns a result by given ammo and armor IDs +func GetByRefs(ammo, armor []string, r *RangeOptions, opts *Options) (*model.Result, error) { + opts.Sort = append(opts.Sort, bson.D{ + bson.E{Key: "ammo", Value: 1}, + bson.E{Key: "armor.id", Value: 1}, + }...) + + filter := bson.D{} + + if ammo != nil { + IDs := make([]objectID, len(ammo)) + for i, id := range ammo { + objID, err := model.ToObjectID(id) + if err != nil { + return &model.Result{}, err + } + + IDs[i] = objID + } + + filter = append(filter, bson.E{Key: "ammo", Value: bson.D{{Key: "$in", Value: IDs}}}) + } + if armor != nil { + IDs := make([]objectID, len(armor)) + for i, id := range armor { + objID, err := model.ToObjectID(id) + if err != nil { + return &model.Result{}, err + } + + IDs[i] = objID + } + + filter = append(filter, bson.E{Key: "armor.id", Value: bson.D{{Key: "$in", Value: IDs}}}) + } + + switch { + case r.GTE != nil && r.LTE != nil: + filter = append(filter, bson.E{Key: "distance", Value: bson.D{ + bson.E{Key: "$gte", Value: r.GTE}, bson.E{Key: "$lte", Value: r.LTE}, + }}) + case r.GTE != nil: + filter = append(filter, bson.E{Key: "distance", Value: bson.D{{Key: "$gte", Value: r.GTE}}}) + case r.LTE != nil: + filter = append(filter, bson.E{Key: "distance", Value: bson.D{{Key: "$lte", Value: r.LTE}}}) + } + + return getManyByFilter(filter, opts) +} + +// Create creates a new entity +func Create(stats *AmmoArmorStatistics) error { + c := database.GetDB().Collection(Collection) + + if stats.ID.IsZero() { + stats.ID = primitive.NewObjectID() + } + + stats.Modified = timestamp{Time: time.Now()} + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if _, err := c.InsertOne(ctx, stats); err != nil { + logger.Error(err) + return model.MongoToAPIError(err) + } + + return nil +} + +// Replace replaces the data of an existing entity +func Replace(id string, stats *AmmoArmorStatistics) error { + objID, err := model.ToObjectID(id) + if err != nil { + return err + } + + if stats.ID.IsZero() { + stats.ID = objID + } + + stats.Modified = timestamp{Time: time.Now()} + + c := database.GetDB().Collection(Collection) + + opts := options.FindOneAndReplace() + opts.SetUpsert(false) + opts.SetReturnDocument(options.After) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err = c.FindOneAndReplace(ctx, bson.M{"_id": objID}, stats, opts).Decode(stats); err != nil { + logger.Error(err) + return model.MongoToAPIError(err) + } + + return nil +} + +// Remove removes an entity +func Remove(id string) error { + objID, err := model.ToObjectID(id) + if err != nil { + return err + } + + c := database.GetDB().Collection(Collection) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if _, err = c.DeleteOne(ctx, bson.M{"_id": objID}); err != nil { + logger.Error(err) + return model.MongoToAPIError(err) + } + + return nil +} diff --git a/model/statistic/ammunition/distance/distance.go b/model/statistic/ammunition/distance/distance.go new file mode 100644 index 0000000..e5e40b1 --- /dev/null +++ b/model/statistic/ammunition/distance/distance.go @@ -0,0 +1,245 @@ +package distance + +import ( + "context" + "errors" + "time" + + "github.com/google/logger" + "github.com/tarkov-database/rest-api/core/database" + "github.com/tarkov-database/rest-api/model" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type objectID = model.ObjectID + +type timestamp = model.Timestamp + +// AmmoDistanceStatistics describes the entity of a ammo distance statistics +type AmmoDistanceStatistics struct { + ID objectID `json:"_id" bson:"_id"` + Reference objectID `json:"ammo" bson:"ammo"` + Distance uint64 `json:"distance" bson:"distance"` + Velocity float64 `json:"velocity" bson:"velocity"` + Damage float64 `json:"damage" bson:"damage"` + PenetrationPower float64 `json:"penetrationPower" bson:"penetrationPower"` + TimeOfFlight float64 `json:"timeOfFlight" bson:"timeOfFlight"` + Drop float64 `json:"drop" bson:"drop"` + Modified timestamp `json:"_modified" bson:"_modified"` +} + +// Validate validates the fields of a DistanceStatistics +func (d AmmoDistanceStatistics) Validate() error { + if d.Reference.IsZero() { + return errors.New("ammo reference id is missing") + } + + return nil +} + +// Collection indicates the MongoDB feature collection +const Collection = "statistics.ammunition.distances" + +func getOneByFilter(filter interface{}) (*AmmoDistanceStatistics, error) { + c := database.GetDB().Collection(Collection) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + stats := &AmmoDistanceStatistics{} + + if err := c.FindOne(ctx, filter).Decode(stats); err != nil { + if err != mongo.ErrNoDocuments { + logger.Error(err) + } + return stats, model.MongoToAPIError(err) + } + + return stats, nil +} + +// GetByID returns the entity of the given ID +func GetByID(id string) (*AmmoDistanceStatistics, error) { + objID, err := model.ToObjectID(id) + if err != nil { + return &AmmoDistanceStatistics{}, err + } + + return getOneByFilter(bson.M{"_id": objID}) +} + +// Options represents the options for a database operation +type Options struct { + Sort bson.D + Limit int64 + Offset int64 +} + +func getManyByFilter(filter interface{}, opts *Options) (*model.Result, error) { + c := database.GetDB().Collection(Collection) + + findOpts := options.Find() + findOpts.SetLimit(opts.Limit) + findOpts.SetSkip(opts.Offset) + findOpts.SetSort(opts.Sort) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var err error + + r := &model.Result{} + + r.Count, err = c.CountDocuments(ctx, filter) + if err != nil { + logger.Error(err) + return r, model.MongoToAPIError(err) + } + + if r.Count == 0 { + return r, nil + } + + cur, err := c.Find(ctx, filter, findOpts) + if err != nil { + if err != mongo.ErrNoDocuments { + logger.Error(err) + } + return r, model.MongoToAPIError(err) + } + + defer cur.Close(ctx) + + for cur.Next(ctx) { + stats := &AmmoDistanceStatistics{} + + if err := cur.Decode(stats); err != nil { + logger.Error(err) + return r, model.MongoToAPIError(err) + } + + r.Items = append(r.Items, stats) + } + + if err := cur.Err(); err != nil { + return r, model.MongoToAPIError(err) + } + + return r, nil +} + +// GetAll returns a result based on filters +func GetAll(opts *Options) (*model.Result, error) { + return getManyByFilter(bson.D{}, opts) +} + +// GetByRefsAndRange returns a result by given IDs and range +func GetByRefsAndRange(ids []string, gte, lte *uint64, opts *Options) (*model.Result, error) { + opts.Sort = append(opts.Sort, bson.D{ + bson.E{Key: "ammo", Value: 1}, + bson.E{Key: "armor.id", Value: 1}, + }...) + + filter := bson.D{} + + if ids != nil { + objIDs := make([]objectID, len(ids)) + for i, id := range ids { + objID, err := model.ToObjectID(id) + if err != nil { + return &model.Result{}, err + } + + objIDs[i] = objID + } + + filter = append(filter, bson.E{Key: "ammo", Value: bson.D{{Key: "$in", Value: objIDs}}}) + } + + switch { + case gte != nil && lte != nil: + filter = append(filter, bson.E{Key: "distance", Value: bson.D{ + bson.E{Key: "$gte", Value: gte}, bson.E{Key: "$lte", Value: lte}, + }}) + case gte != nil: + filter = append(filter, bson.E{Key: "distance", Value: bson.D{{Key: "$gte", Value: gte}}}) + case lte != nil: + filter = append(filter, bson.E{Key: "distance", Value: bson.D{{Key: "$lte", Value: lte}}}) + } + + return getManyByFilter(filter, opts) +} + +// Create creates a new entity +func Create(stats *AmmoDistanceStatistics) error { + c := database.GetDB().Collection(Collection) + + if stats.ID.IsZero() { + stats.ID = primitive.NewObjectID() + } + + stats.Modified = timestamp{Time: time.Now()} + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if _, err := c.InsertOne(ctx, stats); err != nil { + logger.Error(err) + return model.MongoToAPIError(err) + } + + return nil +} + +// Replace replaces the data of an existing entity +func Replace(id string, stats *AmmoDistanceStatistics) error { + objID, err := model.ToObjectID(id) + if err != nil { + return err + } + + if stats.ID.IsZero() { + stats.ID = objID + } + + stats.Modified = timestamp{Time: time.Now()} + + c := database.GetDB().Collection(Collection) + + opts := options.FindOneAndReplace() + opts.SetUpsert(false) + opts.SetReturnDocument(options.After) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err = c.FindOneAndReplace(ctx, bson.M{"_id": objID}, stats, opts).Decode(stats); err != nil { + logger.Error(err) + return model.MongoToAPIError(err) + } + + return nil +} + +// Remove removes an entity +func Remove(id string) error { + objID, err := model.ToObjectID(id) + if err != nil { + return err + } + + c := database.GetDB().Collection(Collection) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if _, err = c.DeleteOne(ctx, bson.M{"_id": objID}); err != nil { + logger.Error(err) + return model.MongoToAPIError(err) + } + + return nil +} diff --git a/route/route.go b/route/route.go index aa34f64..f283733 100644 --- a/route/route.go +++ b/route/route.go @@ -69,6 +69,20 @@ func routes() *httprouter.Router { r.PUT(prefix+"/location/:id/featuregroup/:gid", auth(jwt.ScopeLocationWrite, cntrl.FeatureGroupPUT)) r.DELETE(prefix+"/location/:id/featuregroup/:gid", auth(jwt.ScopeLocationWrite, cntrl.FeatureGroupDELETE)) + // Ammunition distance statistics + r.GET(prefix+"/statistic/ammunition/distance", auth(jwt.ScopeStatisticRead, cntrl.DistanceStatsGET)) + r.GET(prefix+"/statistic/ammunition/distance/:id", auth(jwt.ScopeStatisticRead, cntrl.DistanceStatGET)) + r.POST(prefix+"/statistic/ammunition/distance", auth(jwt.ScopeStatisticWrite, cntrl.DistanceStatPOST)) + r.PUT(prefix+"/statistic/ammunition/distance/:id", auth(jwt.ScopeStatisticWrite, cntrl.DistanceStatPUT)) + r.DELETE(prefix+"/statistic/ammunition/distance/:id", auth(jwt.ScopeStatisticWrite, cntrl.DistanceStatDELETE)) + + // Ammunition armor statistics + r.GET(prefix+"/statistic/ammunition/armor", auth(jwt.ScopeStatisticRead, cntrl.ArmorStatsGET)) + r.GET(prefix+"/statistic/ammunition/armor/:id", auth(jwt.ScopeStatisticRead, cntrl.ArmorStatGET)) + r.POST(prefix+"/statistic/ammunition/armor", auth(jwt.ScopeStatisticWrite, cntrl.ArmorStatPOST)) + r.PUT(prefix+"/statistic/ammunition/armor/:id", auth(jwt.ScopeStatisticWrite, cntrl.ArmorStatPUT)) + r.DELETE(prefix+"/statistic/ammunition/armor/:id", auth(jwt.ScopeStatisticWrite, cntrl.ArmorStatDELETE)) + // User r.GET(prefix+"/user", auth(jwt.ScopeUserRead, cntrl.UsersGET)) r.GET(prefix+"/user/:id", auth(jwt.ScopeUserRead, cntrl.UserGET))