From d34f75f6301430814af9c680968c1b10ad1da54f Mon Sep 17 00:00:00 2001 From: Krish-Patel656 Date: Fri, 31 Oct 2025 23:48:04 -0500 Subject: [PATCH 01/61] Refactor section aggregation pipelines; unify courses & professors; handle single course for /section/:id/course --- api/controllers/section.go | 186 ++++++++++++++++++------------------- 1 file changed, 92 insertions(+), 94 deletions(-) diff --git a/api/controllers/section.go b/api/controllers/section.go index e2ca5f0b..49398fc3 100644 --- a/api/controllers/section.go +++ b/api/controllers/section.go @@ -66,7 +66,7 @@ func SectionSearch(c *gin.Context) { optionLimit, err := configs.GetOptionLimit(&query, c) if err != nil { - respond(c, http.StatusBadRequest, "offset is not type integer", err.Error()) + respond[string](c, http.StatusBadRequest, "offset is not type integer", err.Error()) return } @@ -84,7 +84,7 @@ func SectionSearch(c *gin.Context) { } // return result - respond(c, http.StatusOK, "success", sections) + respond[[]schema.Section](c, http.StatusOK, "success", sections) } // @Id sectionById @@ -116,7 +116,7 @@ func SectionById(c *gin.Context) { } // return result - respond(c, http.StatusOK, "success", section) + respond[schema.Section](c, http.StatusOK, "success", section) } // @Id sectionCourseSearch @@ -175,77 +175,52 @@ func sectionCourse(flag string, c *gin.Context) { defer cancel() var sectionCourses []schema.Course - var sectionQuery bson.M - var err error - if sectionQuery, err = getSectionQuery(flag, c); err != nil { + sectionQuery, err := getSectionQuery(flag, c) + + if err != nil { return } - paginateMap, err := configs.GetAggregateLimit(§ionQuery, c) + rawPaginateMap, err := configs.GetAggregateLimit(§ionQuery, c) if err != nil { respond(c, http.StatusBadRequest, "Error offset is not type integer", err.Error()) return } - // pipeline of query an array of courses from filtered sections - sectionCoursePipeline := mongo.Pipeline{ - // filter the sections - bson.D{{Key: "$match", Value: sectionQuery}}, - - // paginate the sections before pulling courses from those sections - bson.D{{Key: "$skip", Value: paginateMap["former_offset"]}}, - bson.D{{Key: "$limit", Value: paginateMap["limit"]}}, - - // lookup the course referenced by sections from the course collection - bson.D{{Key: "$lookup", Value: bson.D{ - {Key: "from", Value: "courses"}, - {Key: "localField", Value: "course_reference"}, - {Key: "foreignField", Value: "_id"}, - {Key: "as", Value: "course_reference"}, - }}}, - - // project to remove every other fields except for courses - bson.D{{Key: "$project", Value: bson.D{{Key: "courses", Value: "$course_reference"}}}}, - - // unwind the courses - bson.D{{Key: "$unwind", Value: bson.D{ - {Key: "path", Value: "$courses"}, - {Key: "preserveNullAndEmptyArrays", Value: false}, - }}}, - // replace the combinations of id and course with courses entirely - bson.D{{Key: "$replaceWith", Value: "$courses"}}, - - // keep order deterministic between calls - bson.D{{Key: "$sort", Value: bson.D{{Key: "_id", Value: 1}}}}, - - // paginate the courses - bson.D{{Key: "$skip", Value: paginateMap["latter_offset"]}}, - bson.D{{Key: "$limit", Value: paginateMap["limit"]}}, + paginateMap := make(map[string]int) + for k,v := range rawPaginateMap { + paginateMap[k] = int(v) } - cursor, err := sectionCollection.Aggregate(ctx, sectionCoursePipeline) + pipeline := buildSectionPipeline(sectionQuery, paginateMap, "courses", flag == "ById") + cursor, err := sectionCollection.Aggregate(ctx, pipeline) + if err != nil { respondWithInternalError(c, err) return } - - // Parse the array of courses - if err = cursor.All(ctx, §ionCourses); err != nil { - respondWithInternalError(c, err) - return - } - - switch flag { - case "Search": - respond(c, http.StatusOK, "success", sectionCourses) - case "ById": - // Each section is only referenced by only one course, so returning a single course is ideal - // A better way of handling this might be needed in the future - respond(c, http.StatusOK, "success", sectionCourses[0]) + if flag == "ById" { + var course schema.Course + if cursor.Next(ctx) { + if err := cursor.Decode(&course); err != nil { + respondWithInternalError(c,err) + return + } + respond[*schema.Course](c, http.StatusOK, "success", &course) + return + } + respond[interface{}](c, http.StatusOK, "success", nil) + } else { + if err := cursor.All(ctx, §ionCourses); err != nil { + respondWithInternalError(c, err) + return + } + respond[[]schema.Course](c, http.StatusOK, "success", sectionCourses) } } + // @Id sectionProfessorSearch // @Router /section/professors [get] // @Description "Returns paginated list of professors of all the sections matching the query's string-typed key-value pairs. See former_offset and latter_offset for pagination details." @@ -290,72 +265,48 @@ func SectionProfessorSearch() gin.HandlerFunc { // @Success 200 {object} schema.APIResponse[[]schema.Professor] "A list of professors" // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 400 {object} schema.APIResponse[string] "A string describing the error" + func SectionProfessorById() gin.HandlerFunc { return func(c *gin.Context) { sectionProfessor("ById", c) } } -// Get an array of professors from sections, +// Get an array of professors sections, func sectionProfessor(flag string, c *gin.Context) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - var sectionProfessors []schema.Professor - var sectionQuery bson.M - var err error - if sectionQuery, err = getSectionQuery(flag, c); err != nil { + sectionQuery, err := getSectionQuery(flag, c) + + if err != nil { return } - paginateMap, err := configs.GetAggregateLimit(§ionQuery, c) + rawPaginateMap, err := configs.GetAggregateLimit(§ionQuery, c) if err != nil { respond(c, http.StatusBadRequest, "Error offset is not type integer", err.Error()) return } - // pipeline to query an array of professors from filtered sections - sectionProfessorPipeline := mongo.Pipeline{ - bson.D{{Key: "$match", Value: sectionQuery}}, - - bson.D{{Key: "$skip", Value: paginateMap["former_offset"]}}, - bson.D{{Key: "$limit", Value: paginateMap["limit"]}}, - - bson.D{{Key: "$lookup", Value: bson.D{ - {Key: "from", Value: "professors"}, - {Key: "localField", Value: "professors"}, - {Key: "foreignField", Value: "_id"}, - {Key: "as", Value: "professors"}, - }}}, - - bson.D{{Key: "$project", Value: bson.D{{Key: "professors", Value: "$professors"}}}}, - - bson.D{{Key: "$unwind", Value: bson.D{ - {Key: "path", Value: "$professors"}, - {Key: "preserveNullAndEmptyArrays", Value: false}, - }}}, - - bson.D{{Key: "$replaceWith", Value: "$professors"}}, - - bson.D{{Key: "$sort", Value: bson.D{{Key: "_id", Value: 1}}}}, - - bson.D{{Key: "$skip", Value: paginateMap["latter_offset"]}}, - bson.D{{Key: "$limit", Value: paginateMap["limit"]}}, + paginateMap := make(map[string]int) + for k, v := range rawPaginateMap { + paginateMap[k] = int(v) } - cursor, err := sectionCollection.Aggregate(ctx, sectionProfessorPipeline) + pipeline := buildSectionPipeline(sectionQuery, paginateMap, "professors", flag == "ById") + cursor, err := sectionCollection.Aggregate(ctx, pipeline) + if err != nil { respondWithInternalError(c, err) return } - - // Parse the array of courses + var sectionProfessors []schema.Professor if err = cursor.All(ctx, §ionProfessors); err != nil { respondWithInternalError(c, err) return } - - respond(c, http.StatusOK, "success", sectionProfessors) + respond[[]schema.Professor](c, http.StatusOK, "success", sectionProfessors) } // Determine the query of the section based on parameters passed from context. @@ -385,3 +336,50 @@ func getSectionQuery(flag string, c *gin.Context) (bson.M, error) { return sectionQuery, nil } + +func buildSectionPipeline( + sectionQuery bson.M, + paginateMap map[string]int, + lookupType string, + single bool, +) mongo.Pipeline { + localField := "course_reference" + field := lookupType + + if lookupType == "professors" { + localField = "professor_id" + } + pipeline := mongo.Pipeline{ + bson.D{{Key: "$match", Value: sectionQuery}}, + } + if !single { + pipeline = append(pipeline, + bson.D{{Key: "$skip", Value: paginateMap["former_offset"]}}, + bson.D{{Key: "$limit", Value: paginateMap["limit"]}}, + ) +} + +pipeline = append(pipeline, + bson.D{{Key: "$lookup", Value: bson.D{ + {Key: "from", Value: lookupType}, + {Key: "localField", Value: localField}, + {Key: "foreignField", Value: "_id"}, + {Key: "as", Value: field}, + }}}, + bson.D{{Key: "$project", Value: bson.D{{Key: field, Value: "$" + field}}}}, +) + +if !single { + pipeline = append(pipeline, + bson.D{{Key: "$unwind", Value: bson.D{ + {Key: "path", Value: "$" + field}, + {Key: "preserveNullAndEmptyArrays", Value: false}, + }}}, + bson.D{{Key: "$replaceWith", Value: "$" + field}}, + bson.D{{Key: "$sort", Value: bson.D{{Key: "_id", Value: 1}}}}, + bson.D{{Key: "$skip", Value: paginateMap["latter_offset"]}}, + bson.D{{Key: "$limit", Value: paginateMap["limit"]}}, + ) + } + return pipeline +} \ No newline at end of file From 0c7b829a90febeb2310ade46c9cc07d951605012 Mon Sep 17 00:00:00 2001 From: Ian <94922205+SoggyRhino@users.noreply.github.com> Date: Sat, 14 Feb 2026 18:48:50 -0600 Subject: [PATCH 02/61] Rewrite Filter System, created unit tests, updated queryable fields --- api/controllers/controller_utils.go | 2 +- api/controllers/trends.go | 2 +- api/go.mod | 1 + api/schema/filter.go | 164 ++++++++++++-- api/schema/filter_test.go | 337 ++++++++++++++++++++++++++++ api/schema/objects.go | 30 +-- 6 files changed, 495 insertions(+), 41 deletions(-) create mode 100644 api/schema/filter_test.go diff --git a/api/controllers/controller_utils.go b/api/controllers/controller_utils.go index 57579fe1..77555677 100644 --- a/api/controllers/controller_utils.go +++ b/api/controllers/controller_utils.go @@ -31,7 +31,7 @@ func respond[T any](c *gin.Context, status int, message string, data T) { func getQuery[T any](flag string, c *gin.Context) (bson.M, error) { switch flag { case "Search": - query, err := schema.FilterQuery[T](c) + query, err := schema.FilterQuery[T](c.Request.URL.Query()) if err != nil { respond(c, http.StatusBadRequest, "Invalid query parameters", err.Error()) return nil, err diff --git a/api/controllers/trends.go b/api/controllers/trends.go index 6857a8bb..a959c684 100644 --- a/api/controllers/trends.go +++ b/api/controllers/trends.go @@ -62,7 +62,7 @@ func trendsSectionSearch(flag string, c *gin.Context) { } case "Professor": trendsCollection = configs.GetCollection("trends_prof_sections") - trendsQuery, err = schema.FilterQuery[schema.Professor](c) + trendsQuery, err = schema.FilterQuery[schema.Professor](c.Request.URL.Query()) if err != nil { return } diff --git a/api/go.mod b/api/go.mod index ba0710c8..e7e9fce3 100644 --- a/api/go.mod +++ b/api/go.mod @@ -9,6 +9,7 @@ require ( github.com/getsentry/sentry-go v0.33.0 github.com/getsentry/sentry-go/gin v0.33.0 github.com/gin-gonic/gin v1.10.1 + github.com/google/go-cmp v0.7.0 github.com/joho/godotenv v1.5.1 github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.1 diff --git a/api/schema/filter.go b/api/schema/filter.go index 21df52c3..22edfc83 100644 --- a/api/schema/filter.go +++ b/api/schema/filter.go @@ -1,37 +1,153 @@ package schema import ( - "github.com/gin-gonic/gin" + "fmt" + "net/url" + "reflect" + "strconv" + "strings" + "sync" + "go.mongodb.org/mongo-driver/bson" ) -func FilterQuery[F any](c *gin.Context) (bson.M, error) { +var queryableCache sync.Map + +func FilterQuery[F any](urlValues url.Values) (bson.M, error) { + queryable, err := loadQueryable(reflect.TypeFor[F]()) + if err != nil { + return nil, err + } - // Placeholder until filtering is fixed query := bson.M{} - for k := range c.Request.URL.Query() { - query[k] = c.Query(k) - } - - /* - src := c.Request.URL.Query() - dst := make(url.Values) - filter := new(F) - // decode in bson dst - if err := decoder.Decode(filter, src); err != nil { - return nil, err - } - if err := encoder.Encode(filter, dst); err != nil { - return nil, err - } - query := make(bson.M) - // merge dst into bson.M - for k, v := range src { - if dst.Has(k) { - query[k] = v[0] + for key, values := range urlValues { + if key == "offset" { + if num, err := strconv.Atoi(values[0]); err == nil { + query[key] = num + continue + } else { + return nil, fmt.Errorf("offest must be an integer") } } - */ + + // empty string is not necessarily false ? + //if len(values) == 0 || values[0] == "" { + // return nil, fmt.Errorf("query parameter '%s' requires a value", key) + //} + + if len(values) > 1 { + return nil, fmt.Errorf("multi-value queries are not supported") + } + + allowed, exists := queryable[key] + if !exists { + return nil, fmt.Errorf("unknown query parameter '%s'", key) + } + if !allowed { + return nil, fmt.Errorf("field '%s' cannot be used for filtering", key) + } + query[key] = values[0] + } return query, nil } + +func loadQueryable(t reflect.Type) (map[string]bool, error) { + if cached, ok := queryableCache.Load(t.String()); ok { + //should literally never fail but its best practice to check casts + if queryMap, ok := cached.(map[string]bool); ok { + return queryMap, nil + } + queryableCache.Delete(t.String()) + return nil, fmt.Errorf("queryableCache was corrupted: %s was not of type map[string]bool", t.String()) + } + + queryable := make(map[string]bool) + err := recBuild(t, "", queryable, make([]reflect.Type, 0)) + if err != nil { + return nil, err + + } + actual, _ := queryableCache.LoadOrStore(t.String(), queryable) + if queryMap, ok := actual.(map[string]bool); ok { + return queryMap, nil + } + queryableCache.Delete(t.String()) + return nil, fmt.Errorf("queryableCache was corrupted: %s was not of type map[string]bool", t.String()) +} + +func recBuild(t reflect.Type, prefix string, queryableMap map[string]bool, visited []reflect.Type) error { + if willCreateLoop(visited, t) { + return nil + } + + newVisited := append(visited, t) + for t.Kind() == reflect.Ptr { + t = t.Elem() + } + + if t.Kind() != reflect.Struct { + return nil + } + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + + json, hasJson := field.Tag.Lookup("json") + if !hasJson { + return fmt.Errorf("exported field '%s.%s' missing json tag (use json:\"-\" to exclude)", t.Name(), field.Name) + } else if json == "-" { + continue + } + + // Determine the JSON path + fullPath := strings.Split(json, ",")[0] + if prefix != "" { + fullPath = prefix + "." + fullPath + } + + fieldType := field.Type + for fieldType.Kind() == reflect.Ptr { + fieldType = fieldType.Elem() + } + + _, queryable := field.Tag.Lookup("queryable") + if fieldType.Kind() == reflect.Struct { + if queryable { + if err := recBuild(field.Type, fullPath, queryableMap, newVisited); err != nil { + return err + } + } else { + queryableMap[fullPath] = false + } + } else { + queryableMap[fullPath] = queryable + } + } + return nil +} + +// willCreateLoop determines if adding `value` to the `visited` stack creates +// an infinite recursion cycle. +// +// It detects cycles by checking if the `value` (a pointer) has been seen before +// and if its previous "parent" type matches the current "parent" type. +// This allows for type reuse (e.g., A -> B -> A) while blocking +// infinite loops (e.g., A -> *A -> A -> *A). +// +// Non-pointer types are ignored as they cannot cause infinite recursion. +func willCreateLoop(visited []reflect.Type, value reflect.Type) bool { + if value.Kind() != reflect.Ptr { + return false + } + + index := -1 + for i := len(visited) - 1; i >= 0; i-- { + if visited[i] == value { + index = i + break + } + } + + return index > 0 && visited[index-1] == visited[len(visited)-1] +} diff --git a/api/schema/filter_test.go b/api/schema/filter_test.go new file mode 100644 index 00000000..5431394e --- /dev/null +++ b/api/schema/filter_test.go @@ -0,0 +1,337 @@ +package schema + +import ( + "net/url" + "reflect" + "testing" + + "github.com/google/go-cmp/cmp" + "go.mongodb.org/mongo-driver/bson" +) + +type _normal struct { + Name string `bson:"name" json:"name" queryable:""` + Number int `bson:"number" json:"number" queryable:""` + Hidden bool `bson:"hidden" json:"hidden"` +} + +type _missingJson struct { + Name string + Number int + Hidden bool +} + +type _missingQueryable struct { + Name string `bson:"name" json:"name"` + Number int `bson:"number" json:"number"` + Hidden bool `bson:"hidden" json:"hidden"` +} + +type _nested struct { + Name string `bson:"name" json:"name" queryable:""` + Number int `bson:"number" json:"number" queryable:""` + Hidden bool `bson:"hidden" json:"hidden"` + Nested _normal `bson:"nested" json:"nested" queryable:""` +} + +type _nestedAnonymous struct { + Name string `bson:"name" json:"name" queryable:""` + Number int `bson:"number" json:"number" queryable:""` + Hidden bool `bson:"hidden" json:"hidden"` + Nested struct { + Normal _normal `bson:"normal" json:"normal" queryable:""` + NormalHidden _normal `bson:"nested_hidden" json:"nested_hidden"` + } `bson:"nested" json:"nested" queryable:""` + NestedHidden struct { + } `bson:"nested" json:"nested_hidden"` +} + +type _nestedEmbedded struct { + _normal `bson:",inline" json:",inline" queryable:""` +} + +type _nestedPointer struct { + Name string `bson:"name" json:"name" queryable:""` + Number int `bson:"number" json:"number" queryable:""` + Hidden bool `bson:"hidden" json:"hidden"` + Nested *_normal `bson:"nested" json:"nested" queryable:""` +} + +type _nestedDoublePointer struct { + Name string `bson:"name" json:"name" queryable:""` + Number int `bson:"number" json:"number" queryable:""` + Hidden bool `bson:"hidden" json:"hidden"` + Nested *_nestedPointer `bson:"nested" json:"nested" queryable:""` +} + +type _nestedInfinite struct { + Name string `bson:"name" json:"name" queryable:""` + Number int `bson:"number" json:"number" queryable:""` + Hidden bool `bson:"hidden" json:"hidden"` + Nested *_nestedInfinite `bson:"nested" json:"nested" queryable:""` +} + +type _jsonExcluded struct { + Name string `bson:"name" json:"name" queryable:""` + Number int `bson:"-" json:"-"` + Hidden bool `bson:"hidden" json:"hidden"` +} + +func TestFilterQuery(t *testing.T) { + + testCases := map[string]struct { + Function func(values url.Values) (bson.M, error) + UrlQuery map[string][]string + Fail bool + Expected bson.M + }{ + "Normal": { + Function: FilterQuery[_normal], + UrlQuery: map[string][]string{ + "name": {"bob"}, + "number": {"0"}, + }, + Expected: bson.M{ + "name": "bob", + "number": "0", + }, + }, + "Nested": { + Function: FilterQuery[_nested], + UrlQuery: map[string][]string{ + "name": {"bob"}, + "number": {"0"}, + "nested.name": {"bob"}, + "nested.number": {"0"}, + }, + Expected: bson.M{ + "name": "bob", + "number": "0", + "nested.name": "bob", + "nested.number": "0", + }, + }, + "Normal with Offest": { + Function: FilterQuery[_normal], + UrlQuery: map[string][]string{ + "name": {"bob"}, + "offset": {"0"}, + }, + Expected: bson.M{ + "name": "bob", + "offset": 0, + }, + }, + "Fail empty parameter": { + Function: FilterQuery[_nested], + UrlQuery: map[string][]string{ + "": {"false"}, + }, + Fail: true, + }, + "Fail field cannot be queried": { + Function: FilterQuery[_nested], + UrlQuery: map[string][]string{ + "hidden": {"false"}, + }, + Fail: true, + }, + "Fail multiple values": { + Function: FilterQuery[_nested], + UrlQuery: map[string][]string{ + "": {"false", "true"}, + }, + Fail: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + result, err := tc.Function(tc.UrlQuery) + + if tc.Fail { + if err == nil { + t.Fatal("expected error but got nil") + } + + } else { + if err != nil { + t.Errorf("unexpected error %v ", err) + } + + if diff := cmp.Diff(tc.Expected, result); diff != "" { + t.Errorf("Failed (-expected +got)\n %s", diff) + } + } + + if tc.Fail && err == nil { + t.Errorf("expected error, got nil") + } else if !tc.Fail && err != nil { + t.Errorf("unexpected error %v ", err) + } + }) + } + +} + +func TestLoadQueryable(t *testing.T) { + + testcases := map[string]struct { + Type reflect.Type + Expected map[string]bool + Fail bool + }{ + "Normal": { + Type: reflect.TypeFor[_normal](), + Expected: map[string]bool{ + "name": true, + "number": true, + "hidden": false, + }, + }, + "Missing Json": { + Type: reflect.TypeFor[_missingJson](), + Fail: true, + }, + "Missing Queryable": { + Type: reflect.TypeFor[_missingQueryable](), + Expected: map[string]bool{ + "name": false, + "number": false, + "hidden": false, + }, + }, + "Nested": { + Type: reflect.TypeFor[_nested](), + Expected: map[string]bool{ + "name": true, + "number": true, + "hidden": false, + "nested.name": true, + "nested.number": true, + "nested.hidden": false, + }, + }, + "Nested Embedded": { + Type: reflect.TypeFor[_nestedEmbedded](), + Expected: map[string]bool{ + "name": true, + "number": true, + "hidden": false, + }, + }, + "Nested Anonymous": { + Type: reflect.TypeFor[_nestedAnonymous](), + Expected: map[string]bool{ + "name": true, + "number": true, + "hidden": false, + "nested_hidden": false, + "nested.normal.name": true, + "nested.normal.number": true, + "nested.normal.hidden": false, + "nested.nested_hidden": false, + }, + }, + "Nested Pointer": { + Type: reflect.TypeFor[_nestedPointer](), + Expected: map[string]bool{ + "name": true, + "number": true, + "hidden": false, + "nested.name": true, + "nested.number": true, + "nested.hidden": false, + }, + }, + "Nested Double Pointer": { + Type: reflect.TypeFor[_nestedDoublePointer](), + Expected: map[string]bool{ + "name": true, + "number": true, + "hidden": false, + "nested.name": true, + "nested.number": true, + "nested.hidden": false, + "nested.nested.name": true, + "nested.nested.number": true, + "nested.nested.hidden": false, + }, + }, + "Json Excluded": { + Type: reflect.TypeFor[_jsonExcluded](), + Expected: map[string]bool{ + "name": true, + "hidden": false, + }, + }, + "Nested Infinite": { + Type: reflect.TypeFor[_nestedInfinite](), + Expected: map[string]bool{ + "name": true, + "number": true, + "hidden": false, + "nested.name": true, + "nested.number": true, + "nested.hidden": false, + "nested.nested.name": true, + "nested.nested.number": true, + "nested.nested.hidden": false, + }, + }, + } + + for name, tc := range testcases { + t.Run(name, func(t *testing.T) { + result, err := loadQueryable(tc.Type) + + if (err != nil) != tc.Fail { + t.Errorf("loadQueryable() error = %v, fail %v", err, tc.Fail) + return + } + + if diff := cmp.Diff(tc.Expected, result); diff != "" { + t.Errorf("Failed (-expected +got)\n %s", diff) + } + }) + } + + t.Run("Cache Corruption", func(t *testing.T) { + rType := reflect.TypeFor[_normal]() + typeName := rType.String() + + t.Run("Corrupted on Load", func(t *testing.T) { + queryableCache.Store(typeName, 14) + + _, err := loadQueryable(rType) + if err == nil { + t.Fatal("expected error when cache contains wrong type") + } + + if _, exists := queryableCache.Load(typeName); exists { + t.Error("corrupted cache entry should have been deleted") + } + + if _, err = loadQueryable(rType); err != nil { + t.Fatalf("unexpected failure after cache cleared: %v", err) + } + }) + + t.Run("Recovery After Corruption", func(t *testing.T) { + queryableCache.Store(typeName, "wrong type") + + if _, err := loadQueryable(reflect.TypeFor[_normal]()); err == nil { + t.Fatal("expected corruption error") + } + + // first will load, second will be cached + for range 2 { + if _, err := loadQueryable(reflect.TypeFor[_normal]()); err != nil { + t.Fatalf("should recover after corruption: %v", err) + } + } + + }) + }) + +} diff --git a/api/schema/objects.go b/api/schema/objects.go index 4932ad98..88b16d11 100644 --- a/api/schema/objects.go +++ b/api/schema/objects.go @@ -44,22 +44,22 @@ type BasicCourse struct { } type AcademicSession struct { - Name string `bson:"name" json:"name"` + Name string `bson:"name" json:"name" queryable:""` Start_date time.Time `bson:"start_date" json:"start_date"` End_date time.Time `bson:"end_date" json:"end_date"` } type Assistant struct { - First_name string `bson:"first_name" json:"first_name"` - Last_name string `bson:"last_name" json:"last_name"` - Role string `bson:"role" json:"role"` - Email string `bson:"email" json:"email"` + First_name string `bson:"first_name" json:"first_name" queryable:""` + Last_name string `bson:"last_name" json:"last_name" queryable:""` + Role string `bson:"role" json:"role" queryable:""` + Email string `bson:"email" json:"email" queryable:""` } type Location struct { - Building string `bson:"building" json:"building"` - Room string `bson:"room" json:"room"` - Map_uri string `bson:"map_uri" json:"map_uri"` + Building string `bson:"building" json:"building" queryable:""` + Room string `bson:"room" json:"room" queryable:""` + Map_uri string `bson:"map_uri" json:"map_uri" queryable:""` } type Meeting struct { @@ -77,14 +77,14 @@ type Section struct { Section_number string `bson:"section_number" json:"section_number" queryable:""` Course_reference primitive.ObjectID `bson:"course_reference" json:"course_reference" queryable:""` Section_corequisites *CollectionRequirement `bson:"section_corequisites" json:"section_corequisites"` - Academic_session AcademicSession `bson:"academic_session" json:"academic_session"` + Academic_session AcademicSession `bson:"academic_session" json:"academic_session" queryable:""` Professors []primitive.ObjectID `bson:"professors" json:"professors"` Teaching_assistants []Assistant `bson:"teaching_assistants" json:"teaching_assistants"` Internal_class_number string `bson:"internal_class_number" json:"internal_class_number" queryable:""` Instruction_mode string `bson:"instruction_mode" json:"instruction_mode" queryable:""` Meetings []Meeting `bson:"meetings" json:"meetings"` Core_flags []string `bson:"core_flags" json:"core_flags"` - Syllabus_uri string `bson:"syllabus_uri" json:"syllabus_uri"` + Syllabus_uri string `bson:"syllabus_uri" json:"syllabus_uri" queryable:""` Grade_distribution []int `bson:"grade_distribution" json:"grade_distribution"` Attributes interface{} `bson:"attributes" json:"attributes"` Professor_details *[]BasicProfessor `bson:"professor_details,omitempty" json:"professor_details,omitempty"` // only shows if professor_details was set by the endpoint @@ -95,12 +95,12 @@ type Professor struct { Id primitive.ObjectID `bson:"_id" json:"_id"` First_name string `bson:"first_name" json:"first_name" queryable:""` Last_name string `bson:"last_name" json:"last_name" queryable:""` - Titles []string `bson:"titles" json:"titles" queryable:""` + Titles []string `bson:"titles" json:"titles" ` Email string `bson:"email" json:"email" queryable:""` Phone_number string `bson:"phone_number" json:"phone_number" queryable:""` - Office Location `bson:"office" json:"office"` - Profile_uri string `bson:"profile_uri" json:"profile_uri"` - Image_uri string `bson:"image_uri" json:"image_uri"` + Office Location `bson:"office" json:"office" queryable:""` + Profile_uri string `bson:"profile_uri" json:"profile_uri" queryable:""` + Image_uri string `bson:"image_uri" json:"image_uri" queryable:""` Office_hours []Meeting `bson:"office_hours" json:"office_hours"` Sections []primitive.ObjectID `bson:"sections" json:"sections"` } @@ -111,7 +111,7 @@ type BasicProfessor struct { Last_name string `bson:"last_name" json:"last_name" queryable:""` Email string `bson:"email" json:"email" queryable:""` Phone_number string `bson:"phone_number" json:"phone_number" queryable:""` - Office Location `bson:"office" json:"office"` + Office Location `bson:"office" json:"office" queryable:""` Office_hours []Meeting `bson:"office_hours" json:"office_hours"` } From 994303e2801f396174366b64d4c2f954b9ca90ab Mon Sep 17 00:00:00 2001 From: Ian <94922205+SoggyRhino@users.noreply.github.com> Date: Sat, 14 Feb 2026 19:28:19 -0600 Subject: [PATCH 03/61] Updated documentation --- api/schema/filter.go | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/api/schema/filter.go b/api/schema/filter.go index 22edfc83..f1bb48e2 100644 --- a/api/schema/filter.go +++ b/api/schema/filter.go @@ -13,6 +13,20 @@ import ( var queryableCache sync.Map +// FilterQuery converts URL query parameters into a MongoDB BSON query filter. +// It validates that each query parameter corresponds to a field in type F that is +// marked as queryable. Additionally, parameter offest can be included, referring to +// mongo query pagination. +// +// Notes: +// - Currently filter only supports exact matching and treats fields as strings +// - More "meta" filters may need to be added in the future. +// +// Returns an error if: +// - A query parameter key is not defined in the struct +// - A field exists but is not marked as queryable +// - Multiple values are provided for a single parameter +// - The "offset" parameter cannot be parsed as an integer func FilterQuery[F any](urlValues url.Values) (bson.M, error) { queryable, err := loadQueryable(reflect.TypeFor[F]()) if err != nil { @@ -30,11 +44,6 @@ func FilterQuery[F any](urlValues url.Values) (bson.M, error) { } } - // empty string is not necessarily false ? - //if len(values) == 0 || values[0] == "" { - // return nil, fmt.Errorf("query parameter '%s' requires a value", key) - //} - if len(values) > 1 { return nil, fmt.Errorf("multi-value queries are not supported") } @@ -52,6 +61,7 @@ func FilterQuery[F any](urlValues url.Values) (bson.M, error) { return query, nil } +// loadQueryable returns a map indicating which fields of the given type are queryable. func loadQueryable(t reflect.Type) (map[string]bool, error) { if cached, ok := queryableCache.Load(t.String()); ok { //should literally never fail but its best practice to check casts @@ -76,6 +86,9 @@ func loadQueryable(t reflect.Type) (map[string]bool, error) { return nil, fmt.Errorf("queryableCache was corrupted: %s was not of type map[string]bool", t.String()) } +// recBuild recursively traverses a struct type to build a map of queryable fields. +// It constructs dot-notation paths for nested fields and determines whether each field +// can be used for filtering based on the "queryable" tag. func recBuild(t reflect.Type, prefix string, queryableMap map[string]bool, visited []reflect.Type) error { if willCreateLoop(visited, t) { return nil @@ -127,15 +140,8 @@ func recBuild(t reflect.Type, prefix string, queryableMap map[string]bool, visit return nil } -// willCreateLoop determines if adding `value` to the `visited` stack creates -// an infinite recursion cycle. -// -// It detects cycles by checking if the `value` (a pointer) has been seen before -// and if its previous "parent" type matches the current "parent" type. -// This allows for type reuse (e.g., A -> B -> A) while blocking -// infinite loops (e.g., A -> *A -> A -> *A). -// -// Non-pointer types are ignored as they cannot cause infinite recursion. +// willCreateLoop determines if adding `value` to the `visited` list would create +// a loop. func willCreateLoop(visited []reflect.Type, value reflect.Type) bool { if value.Kind() != reflect.Ptr { return false From 240b98b89b9f826e900b5dd949099e21ac9d3857 Mon Sep 17 00:00:00 2001 From: Ian <94922205+SoggyRhino@users.noreply.github.com> Date: Sat, 14 Feb 2026 22:25:13 -0600 Subject: [PATCH 04/61] Fix bugs with slices and base fields. Changed queryable fields --- api/schema/filter.go | 50 +++++++++++++++++++++++---------------- api/schema/filter_test.go | 48 +++++++++++++++++++++++++++++++++---- api/schema/objects.go | 32 ++++++++++++------------- 3 files changed, 89 insertions(+), 41 deletions(-) diff --git a/api/schema/filter.go b/api/schema/filter.go index f1bb48e2..47b2e44f 100644 --- a/api/schema/filter.go +++ b/api/schema/filter.go @@ -7,11 +7,19 @@ import ( "strconv" "strings" "sync" + "time" "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" ) -var queryableCache sync.Map +var ( + queryableCache sync.Map + baseStruct = map[reflect.Type]bool{ + reflect.TypeFor[time.Time](): true, + reflect.TypeFor[primitive.ObjectID](): true, + } +) // FilterQuery converts URL query parameters into a MongoDB BSON query filter. // It validates that each query parameter corresponds to a field in type F that is @@ -40,7 +48,7 @@ func FilterQuery[F any](urlValues url.Values) (bson.M, error) { query[key] = num continue } else { - return nil, fmt.Errorf("offest must be an integer") + return nil, fmt.Errorf("offset must be an integer") } } @@ -73,11 +81,10 @@ func loadQueryable(t reflect.Type) (map[string]bool, error) { } queryable := make(map[string]bool) - err := recBuild(t, "", queryable, make([]reflect.Type, 0)) - if err != nil { + if err := recBuild(t, "", queryable, make([]reflect.Type, 0)); err != nil { return nil, err - } + actual, _ := queryableCache.LoadOrStore(t.String(), queryable) if queryMap, ok := actual.(map[string]bool); ok { return queryMap, nil @@ -95,9 +102,7 @@ func recBuild(t reflect.Type, prefix string, queryableMap map[string]bool, visit } newVisited := append(visited, t) - for t.Kind() == reflect.Ptr { - t = t.Elem() - } + t = drillType(t) if t.Kind() != reflect.Struct { return nil @@ -105,10 +110,9 @@ func recBuild(t reflect.Type, prefix string, queryableMap map[string]bool, visit for i := 0; i < t.NumField(); i++ { field := t.Field(i) - json, hasJson := field.Tag.Lookup("json") if !hasJson { - return fmt.Errorf("exported field '%s.%s' missing json tag (use json:\"-\" to exclude)", t.Name(), field.Name) + return fmt.Errorf("exported field '%s.%s' missing json tag", t.Name(), field.Name) } else if json == "-" { continue } @@ -119,15 +123,14 @@ func recBuild(t reflect.Type, prefix string, queryableMap map[string]bool, visit fullPath = prefix + "." + fullPath } - fieldType := field.Type - for fieldType.Kind() == reflect.Ptr { - fieldType = fieldType.Elem() - } - + fieldType := drillType(field.Type) _, queryable := field.Tag.Lookup("queryable") if fieldType.Kind() == reflect.Struct { if queryable { - if err := recBuild(field.Type, fullPath, queryableMap, newVisited); err != nil { + // do not recurse into time.Time + if _, ok := baseStruct[fieldType]; ok { + queryableMap[fullPath] = true + } else if err := recBuild(field.Type, fullPath, queryableMap, newVisited); err != nil { return err } } else { @@ -147,13 +150,18 @@ func willCreateLoop(visited []reflect.Type, value reflect.Type) bool { return false } - index := -1 - for i := len(visited) - 1; i >= 0; i-- { + for i := len(visited) - 1; i > 0; i-- { if visited[i] == value { - index = i - break + return visited[i-1] == visited[len(visited)-1] } } + return false +} - return index > 0 && visited[index-1] == visited[len(visited)-1] +// drill type gets the base of a type, removing pointers and slices/arrays +func drillType(t reflect.Type) reflect.Type { + for t.Kind() == reflect.Ptr || t.Kind() == reflect.Slice || t.Kind() == reflect.Array { + t = t.Elem() + } + return t } diff --git a/api/schema/filter_test.go b/api/schema/filter_test.go index 5431394e..300c5ed9 100644 --- a/api/schema/filter_test.go +++ b/api/schema/filter_test.go @@ -71,6 +71,22 @@ type _nestedInfinite struct { Nested *_nestedInfinite `bson:"nested" json:"nested" queryable:""` } +type _slice struct { + Names []string `bson:"names" json:"names" queryable:""` +} + +type _array struct { + Names [12]string `bson:"names" json:"names" queryable:""` +} + +type _slicePointer struct { + Names []*string `bson:"names" json:"names" queryable:""` +} + +type _sliceDoublePointer struct { + Names *[]*string `bson:"names" json:"names" queryable:""` +} + type _jsonExcluded struct { Name string `bson:"name" json:"name" queryable:""` Number int `bson:"-" json:"-"` @@ -258,11 +274,28 @@ func TestLoadQueryable(t *testing.T) { "nested.nested.hidden": false, }, }, - "Json Excluded": { - Type: reflect.TypeFor[_jsonExcluded](), + "Slice": { + Type: reflect.TypeFor[_slice](), Expected: map[string]bool{ - "name": true, - "hidden": false, + "names": true, + }, + }, + "Array": { + Type: reflect.TypeFor[_array](), + Expected: map[string]bool{ + "names": true, + }, + }, + "Slice Pointer": { + Type: reflect.TypeFor[_slicePointer](), + Expected: map[string]bool{ + "names": true, + }, + }, + "Slice Double Pointer": { + Type: reflect.TypeFor[_sliceDoublePointer](), + Expected: map[string]bool{ + "names": true, }, }, "Nested Infinite": { @@ -279,6 +312,13 @@ func TestLoadQueryable(t *testing.T) { "nested.nested.hidden": false, }, }, + "Json Excluded": { + Type: reflect.TypeFor[_jsonExcluded](), + Expected: map[string]bool{ + "name": true, + "hidden": false, + }, + }, } for name, tc := range testcases { diff --git a/api/schema/objects.go b/api/schema/objects.go index 88b16d11..f0802069 100644 --- a/api/schema/objects.go +++ b/api/schema/objects.go @@ -13,7 +13,7 @@ type Course struct { Subject_prefix string `bson:"subject_prefix" json:"subject_prefix" queryable:""` Course_number string `bson:"course_number" json:"course_number" queryable:""` Title string `bson:"title" json:"title" queryable:""` - Description string `bson:"description" json:"description"` + Description string `bson:"description" json:"description" queryable:""` Enrollment_reqs string `bson:"enrollment_reqs" json:"enrollment_reqs"` School string `bson:"school" json:"school" queryable:""` Credit_hours string `bson:"credit_hours" json:"credit_hours" queryable:""` @@ -45,8 +45,8 @@ type BasicCourse struct { type AcademicSession struct { Name string `bson:"name" json:"name" queryable:""` - Start_date time.Time `bson:"start_date" json:"start_date"` - End_date time.Time `bson:"end_date" json:"end_date"` + Start_date time.Time `bson:"start_date" json:"start_date" queryable:""` + End_date time.Time `bson:"end_date" json:"end_date" queryable:""` } type Assistant struct { @@ -63,13 +63,13 @@ type Location struct { } type Meeting struct { - Start_date time.Time `bson:"start_date" json:"start_date"` - End_date time.Time `bson:"end_date" json:"end_date"` - Meeting_days []string `bson:"meeting_days" json:"meeting_days"` - Start_time string `bson:"start_time" json:"start_time"` - End_time string `bson:"end_time" json:"end_time"` - Modality string `bson:"modality" json:"modality"` - Location Location `bson:"location" json:"location"` + Start_date time.Time `bson:"start_date" json:"start_date" queryable:""` + End_date time.Time `bson:"end_date" json:"end_date" queryable:""` + Meeting_days []string `bson:"meeting_days" json:"meeting_days" queryable:""` + Start_time string `bson:"start_time" json:"start_time" queryable:""` + End_time string `bson:"end_time" json:"end_time" queryable:""` + Modality string `bson:"modality" json:"modality" queryable:""` + Location Location `bson:"location" json:"location" queryable:""` } type Section struct { @@ -79,11 +79,11 @@ type Section struct { Section_corequisites *CollectionRequirement `bson:"section_corequisites" json:"section_corequisites"` Academic_session AcademicSession `bson:"academic_session" json:"academic_session" queryable:""` Professors []primitive.ObjectID `bson:"professors" json:"professors"` - Teaching_assistants []Assistant `bson:"teaching_assistants" json:"teaching_assistants"` + Teaching_assistants []Assistant `bson:"teaching_assistants" json:"teaching_assistants" queryable:""` Internal_class_number string `bson:"internal_class_number" json:"internal_class_number" queryable:""` Instruction_mode string `bson:"instruction_mode" json:"instruction_mode" queryable:""` - Meetings []Meeting `bson:"meetings" json:"meetings"` - Core_flags []string `bson:"core_flags" json:"core_flags"` + Meetings []Meeting `bson:"meetings" json:"meetings" queryable:""` + Core_flags []string `bson:"core_flags" json:"core_flags" queryable:""` Syllabus_uri string `bson:"syllabus_uri" json:"syllabus_uri" queryable:""` Grade_distribution []int `bson:"grade_distribution" json:"grade_distribution"` Attributes interface{} `bson:"attributes" json:"attributes"` @@ -95,13 +95,13 @@ type Professor struct { Id primitive.ObjectID `bson:"_id" json:"_id"` First_name string `bson:"first_name" json:"first_name" queryable:""` Last_name string `bson:"last_name" json:"last_name" queryable:""` - Titles []string `bson:"titles" json:"titles" ` + Titles []string `bson:"titles" json:"titles" queryable:""` Email string `bson:"email" json:"email" queryable:""` Phone_number string `bson:"phone_number" json:"phone_number" queryable:""` Office Location `bson:"office" json:"office" queryable:""` Profile_uri string `bson:"profile_uri" json:"profile_uri" queryable:""` Image_uri string `bson:"image_uri" json:"image_uri" queryable:""` - Office_hours []Meeting `bson:"office_hours" json:"office_hours"` + Office_hours []Meeting `bson:"office_hours" json:"office_hours" queryable:""` Sections []primitive.ObjectID `bson:"sections" json:"sections"` } @@ -111,7 +111,7 @@ type BasicProfessor struct { Last_name string `bson:"last_name" json:"last_name" queryable:""` Email string `bson:"email" json:"email" queryable:""` Phone_number string `bson:"phone_number" json:"phone_number" queryable:""` - Office Location `bson:"office" json:"office" queryable:""` + Office Location `bson:"office" json:"office"` Office_hours []Meeting `bson:"office_hours" json:"office_hours"` } From 58beb4ba0a523b2fe1b1a32a5c2fb06b40759fdd Mon Sep 17 00:00:00 2001 From: Ian <94922205+SoggyRhino@users.noreply.github.com> Date: Sat, 14 Feb 2026 22:43:49 -0600 Subject: [PATCH 05/61] Fix bugs with slices and base types, changed queryable fields, fixed offest parameter, fixed multivalue parameters --- api/schema/filter.go | 32 ++++++++++++-------------------- api/schema/filter_test.go | 17 ++++------------- 2 files changed, 16 insertions(+), 33 deletions(-) diff --git a/api/schema/filter.go b/api/schema/filter.go index 47b2e44f..4f8bac54 100644 --- a/api/schema/filter.go +++ b/api/schema/filter.go @@ -4,7 +4,6 @@ import ( "fmt" "net/url" "reflect" - "strconv" "strings" "sync" "time" @@ -19,21 +18,18 @@ var ( reflect.TypeFor[time.Time](): true, reflect.TypeFor[primitive.ObjectID](): true, } + ignoredParameters = map[string]bool{ + "offset": true, + } ) // FilterQuery converts URL query parameters into a MongoDB BSON query filter. // It validates that each query parameter corresponds to a field in type F that is -// marked as queryable. Additionally, parameter offest can be included, referring to -// mongo query pagination. -// -// Notes: -// - Currently filter only supports exact matching and treats fields as strings -// - More "meta" filters may need to be added in the future. +// marked as queryable. // // Returns an error if: // - A query parameter key is not defined in the struct // - A field exists but is not marked as queryable -// - Multiple values are provided for a single parameter // - The "offset" parameter cannot be parsed as an integer func FilterQuery[F any](urlValues url.Values) (bson.M, error) { queryable, err := loadQueryable(reflect.TypeFor[F]()) @@ -43,17 +39,8 @@ func FilterQuery[F any](urlValues url.Values) (bson.M, error) { query := bson.M{} for key, values := range urlValues { - if key == "offset" { - if num, err := strconv.Atoi(values[0]); err == nil { - query[key] = num - continue - } else { - return nil, fmt.Errorf("offset must be an integer") - } - } - - if len(values) > 1 { - return nil, fmt.Errorf("multi-value queries are not supported") + if _, ok := ignoredParameters[key]; ok { + continue } allowed, exists := queryable[key] @@ -63,7 +50,12 @@ func FilterQuery[F any](urlValues url.Values) (bson.M, error) { if !allowed { return nil, fmt.Errorf("field '%s' cannot be used for filtering", key) } - query[key] = values[0] + + if len(values) > 1 { + query[key] = bson.M{"$in": values} + } else { + query[key] = values[0] + } } return query, nil diff --git a/api/schema/filter_test.go b/api/schema/filter_test.go index 300c5ed9..8e34d88b 100644 --- a/api/schema/filter_test.go +++ b/api/schema/filter_test.go @@ -127,15 +127,13 @@ func TestFilterQuery(t *testing.T) { "nested.number": "0", }, }, - "Normal with Offest": { - Function: FilterQuery[_normal], + "Multiple values": { + Function: FilterQuery[_nested], UrlQuery: map[string][]string{ - "name": {"bob"}, - "offset": {"0"}, + "name": {"false", "true"}, }, Expected: bson.M{ - "name": "bob", - "offset": 0, + "name": bson.M{"$in": []string{"false", "true"}}, }, }, "Fail empty parameter": { @@ -152,13 +150,6 @@ func TestFilterQuery(t *testing.T) { }, Fail: true, }, - "Fail multiple values": { - Function: FilterQuery[_nested], - UrlQuery: map[string][]string{ - "": {"false", "true"}, - }, - Fail: true, - }, } for name, tc := range testCases { From 0f6606b4dc6f8a15f0b8e70de13512f4840d092b Mon Sep 17 00:00:00 2001 From: Ian <94922205+SoggyRhino@users.noreply.github.com> Date: Sat, 14 Feb 2026 22:54:33 -0600 Subject: [PATCH 06/61] Fix comment typo in drillType function --- api/schema/filter.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/schema/filter.go b/api/schema/filter.go index 4f8bac54..28bdd9cd 100644 --- a/api/schema/filter.go +++ b/api/schema/filter.go @@ -150,7 +150,7 @@ func willCreateLoop(visited []reflect.Type, value reflect.Type) bool { return false } -// drill type gets the base of a type, removing pointers and slices/arrays +// drillType gets the base of a type, removing pointers and slices/arrays func drillType(t reflect.Type) reflect.Type { for t.Kind() == reflect.Ptr || t.Kind() == reflect.Slice || t.Kind() == reflect.Array { t = t.Elem() From 859534e70db17f2fbc31a978041339cd0972fe6c Mon Sep 17 00:00:00 2001 From: Ian <94922205+SoggyRhino@users.noreply.github.com> Date: Sat, 14 Feb 2026 22:55:11 -0600 Subject: [PATCH 07/61] Fix doc --- api/schema/filter.go | 1 - 1 file changed, 1 deletion(-) diff --git a/api/schema/filter.go b/api/schema/filter.go index 28bdd9cd..6390bde1 100644 --- a/api/schema/filter.go +++ b/api/schema/filter.go @@ -30,7 +30,6 @@ var ( // Returns an error if: // - A query parameter key is not defined in the struct // - A field exists but is not marked as queryable -// - The "offset" parameter cannot be parsed as an integer func FilterQuery[F any](urlValues url.Values) (bson.M, error) { queryable, err := loadQueryable(reflect.TypeFor[F]()) if err != nil { From f0c56ae7aeb1c1f6374f716308b1ff5716a250b2 Mon Sep 17 00:00:00 2001 From: Ian <94922205+SoggyRhino@users.noreply.github.com> Date: Sat, 14 Feb 2026 23:16:43 -0600 Subject: [PATCH 08/61] Remove $in on multiple values --- api/schema/filter.go | 6 +++--- api/schema/filter_test.go | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/api/schema/filter.go b/api/schema/filter.go index 4f8bac54..cc0a5ae2 100644 --- a/api/schema/filter.go +++ b/api/schema/filter.go @@ -51,8 +51,8 @@ func FilterQuery[F any](urlValues url.Values) (bson.M, error) { return nil, fmt.Errorf("field '%s' cannot be used for filtering", key) } - if len(values) > 1 { - query[key] = bson.M{"$in": values} + if len(values) == 0 { + query[key] = "" } else { query[key] = values[0] } @@ -150,7 +150,7 @@ func willCreateLoop(visited []reflect.Type, value reflect.Type) bool { return false } -// drill type gets the base of a type, removing pointers and slices/arrays +// drillType gets the base of a type, removing pointers and slices/arrays func drillType(t reflect.Type) reflect.Type { for t.Kind() == reflect.Ptr || t.Kind() == reflect.Slice || t.Kind() == reflect.Array { t = t.Elem() diff --git a/api/schema/filter_test.go b/api/schema/filter_test.go index 8e34d88b..02555fce 100644 --- a/api/schema/filter_test.go +++ b/api/schema/filter_test.go @@ -130,10 +130,10 @@ func TestFilterQuery(t *testing.T) { "Multiple values": { Function: FilterQuery[_nested], UrlQuery: map[string][]string{ - "name": {"false", "true"}, + "name": {"first", "true"}, }, Expected: bson.M{ - "name": bson.M{"$in": []string{"false", "true"}}, + "name": "first", }, }, "Fail empty parameter": { From 5f204af339b8bb4712ed98c5715000361dd92a21 Mon Sep 17 00:00:00 2001 From: Justin Date: Thu, 26 Feb 2026 20:57:31 -0600 Subject: [PATCH 09/61] Create Initial Email Endpoint --- api/controllers/email.go | 80 ++++++++++++++++++++++++++++++++++++++++ api/docs/docs.go | 66 +++++++++++++++++++++++++++++++++ api/docs/swagger.yaml | 44 ++++++++++++++++++++++ api/go.mod | 1 + api/go.sum | 2 + api/routes/email.go | 15 ++++++++ api/server.go | 1 + 7 files changed, 209 insertions(+) create mode 100644 api/controllers/email.go create mode 100644 api/routes/email.go diff --git a/api/controllers/email.go b/api/controllers/email.go new file mode 100644 index 00000000..e243696a --- /dev/null +++ b/api/controllers/email.go @@ -0,0 +1,80 @@ +package controllers + +import ( + "net/http" + "os" + + _ "github.com/UTDNebula/nebula-api/api/schema" + "github.com/gin-gonic/gin" + "github.com/wneessen/go-mail" +) + +type EmailRequest struct { + From string `json:"from" binding:"required"` + To string `json:"to" binding:"required,email"` + Subject string `json:"subject" binding:"required"` + Body string `json:"body" binding:"required"` +} + +// @Id sendEmail +// @Router /email [post] +// @Description "Send an email via SMTP" +// @Accept json +// @Produce json +// @Param request body EmailRequest true "Email Request Body" +// @Success 200 {object} schema.APIResponse[string] "Email sent successfully" +// @Failure 500 {object} schema.APIResponse[string] "A string describing the error" +// @Failure 400 {object} schema.APIResponse[string] "A string describing the error" +func SendEmail(c *gin.Context) { + + var req EmailRequest + + if err := c.ShouldBindJSON(&req); err != nil { + respond(c, http.StatusBadRequest, "invalid request payload", err.Error()) + return + } + + smtpHost := os.Getenv("SMTP_HOST") // TODO: We should be using env.go for this + smtpUser := os.Getenv("SMTP_USERNAME") + smtpPass := os.Getenv("SMTP_PASSWORD") + smtpFrom := os.Getenv("SMTP_FROM") + + if smtpHost == "" || smtpFrom == "" { + respond(c, http.StatusInternalServerError, "SMTP configuration is missing", "SMTP environment variables are not fully configured") + return + } + + m := mail.NewMsg() + if err := m.FromFormat(req.From, smtpFrom); err != nil { + respond(c, http.StatusInternalServerError, "failed to set from address", err.Error()) + return + } + + if err := m.To(req.To); err != nil { + respond(c, http.StatusBadRequest, "invalid to address", err.Error()) + return + } + + m.Subject(req.Subject) + m.SetBodyString(mail.TypeTextPlain, req.Body) + + client, err := mail.NewClient( + smtpHost, + mail.WithTLSPortPolicy(mail.TLSMandatory), + mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover), + mail.WithUsername(smtpUser), + mail.WithPassword(smtpPass), + ) + + if err != nil { + respond(c, http.StatusInternalServerError, "failed to setup SMTP client", err.Error()) + return + } + + if err := client.DialAndSend(m); err != nil { + respond(c, http.StatusInternalServerError, "failed to send email", err.Error()) + return + } + + respond(c, http.StatusOK, "success", "Email sent successfully") +} diff --git a/api/docs/docs.go b/api/docs/docs.go index 4ec27356..fe590ab5 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -891,6 +891,49 @@ const docTemplate = `{ } } }, + "/email": { + "post": { + "description": "\"Send an email via SMTP\"", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "operationId": "sendEmail", + "parameters": [ + { + "description": "Email Request Body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.EmailRequest" + } + } + ], + "responses": { + "200": { + "description": "Email sent successfully", + "schema": { + "$ref": "#/definitions/schema.APIResponse-string" + } + }, + "400": { + "description": "A string describing the error", + "schema": { + "$ref": "#/definitions/schema.APIResponse-string" + } + }, + "500": { + "description": "A string describing the error", + "schema": { + "$ref": "#/definitions/schema.APIResponse-string" + } + } + } + } + }, "/events/{date}": { "get": { "description": "\"Returns all sections with meetings on the specified date\"", @@ -3020,6 +3063,29 @@ const docTemplate = `{ } }, "definitions": { + "controllers.EmailRequest": { + "type": "object", + "required": [ + "body", + "from", + "subject", + "to" + ], + "properties": { + "body": { + "type": "string" + }, + "from": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "to": { + "type": "string" + } + } + }, "schema.APIResponse-array_int": { "type": "object", "properties": { diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index c562d228..65969c3f 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -1,4 +1,20 @@ definitions: + controllers.EmailRequest: + properties: + body: + type: string + from: + type: string + subject: + type: string + to: + type: string + required: + - body + - from + - subject + - to + type: object schema.APIResponse-array_int: properties: data: @@ -1433,6 +1449,34 @@ paths: $ref: '#/definitions/schema.APIResponse-string' tags: - Courses + /email: + post: + consumes: + - application/json + description: '"Send an email via SMTP"' + operationId: sendEmail + parameters: + - description: Email Request Body + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.EmailRequest' + produces: + - application/json + responses: + "200": + description: Email sent successfully + schema: + $ref: '#/definitions/schema.APIResponse-string' + "400": + description: A string describing the error + schema: + $ref: '#/definitions/schema.APIResponse-string' + "500": + description: A string describing the error + schema: + $ref: '#/definitions/schema.APIResponse-string' /events/{date}: get: description: '"Returns all sections with meetings on the specified date"' diff --git a/api/go.mod b/api/go.mod index ba0710c8..006e9a76 100644 --- a/api/go.mod +++ b/api/go.mod @@ -13,6 +13,7 @@ require ( github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.1 github.com/swaggo/swag v1.16.6 + github.com/wneessen/go-mail v0.7.2 go.mongodb.org/mongo-driver v1.17.4 google.golang.org/api v0.224.0 ) diff --git a/api/go.sum b/api/go.sum index 9d0e27e4..3191141c 100644 --- a/api/go.sum +++ b/api/go.sum @@ -189,6 +189,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8= +github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= diff --git a/api/routes/email.go b/api/routes/email.go new file mode 100644 index 00000000..90ca870a --- /dev/null +++ b/api/routes/email.go @@ -0,0 +1,15 @@ +package routes + +import ( + "github.com/gin-gonic/gin" + + "github.com/UTDNebula/nebula-api/api/controllers" +) + +func EmailRoute(router *gin.Engine) { + // All routes related to email come here + emailGroup := router.Group("/email") + + emailGroup.OPTIONS("", controllers.Preflight) + emailGroup.POST("", controllers.SendEmail) +} diff --git a/api/server.go b/api/server.go index 850b3530..539c3c2f 100644 --- a/api/server.go +++ b/api/server.go @@ -85,6 +85,7 @@ func main() { routes.AstraRoute(router) routes.MazevoRoute(router) routes.CalendarRoute(router) + routes.EmailRoute(router) // Retrieve the port string to serve traffic on portString := configs.GetPortString() From 2a6298782e3701b13104227d94457963ec0420bc Mon Sep 17 00:00:00 2001 From: Justin Date: Sun, 1 Mar 2026 21:06:00 -0600 Subject: [PATCH 10/61] Add basic queue --- api/controllers/email.go | 118 ++++++++++++++++++++++++++++++++++++- api/go.mod | 58 +++++++++--------- api/go.sum | 124 +++++++++++++++++++++------------------ api/routes/email.go | 3 +- 4 files changed, 215 insertions(+), 88 deletions(-) diff --git a/api/controllers/email.go b/api/controllers/email.go index e243696a..d296270c 100644 --- a/api/controllers/email.go +++ b/api/controllers/email.go @@ -1,12 +1,17 @@ package controllers import ( + "context" + "fmt" "net/http" "os" _ "github.com/UTDNebula/nebula-api/api/schema" "github.com/gin-gonic/gin" "github.com/wneessen/go-mail" + + cloudtasks "cloud.google.com/go/cloudtasks/apiv2" + taskspb "cloud.google.com/go/cloudtasks/apiv2/cloudtaskspb" ) type EmailRequest struct { @@ -18,9 +23,9 @@ type EmailRequest struct { // @Id sendEmail // @Router /email [post] -// @Description "Send an email via SMTP" +// @Description "Send an email via SMTP" // @Accept json -// @Produce json +// @Produce json // @Param request body EmailRequest true "Email Request Body" // @Success 200 {object} schema.APIResponse[string] "Email sent successfully" // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" @@ -78,3 +83,112 @@ func SendEmail(c *gin.Context) { respond(c, http.StatusOK, "success", "Email sent successfully") } + +func QueueEmail(c *gin.Context) { + + // Request must be able to bind to email request + if err := c.ShouldBindJSON(&EmailRequest{}); err != nil { + respond(c, http.StatusBadRequest, "invalid request payload", err.Error()) + return + } + + var body []byte + c.Request.Body.Read(body) + + queuePath := os.Getenv("QUEUE_PATH") + url := os.Getenv("EMAIL_URL") // TODO: Consider a different name + + _, err := createHTTPTask(queuePath, url, body) + + if err != nil { + respond(c, http.StatusInternalServerError, "failed to queue email", err.Error()) + return + } + + respond(c, http.StatusOK, "success", "Email queued successfully") // TODO: Change the response + +} + +// createHTTPTask creates a new task with a HTTP target then adds it to a Queue. +func createHTTPTask(queuePath string, url string, body []byte) (*taskspb.Task, error) { + + // Create a new Cloud Tasks client instance. + // See https://godoc.org/cloud.google.com/go/cloudtasks/apiv2 + ctx := context.Background() + client, err := cloudtasks.NewClient(ctx) + if err != nil { + return nil, fmt.Errorf("NewClient: %w", err) + } + defer client.Close() + + // Build the Task payload. + // https://godoc.org/google.golang.org/genproto/googleapis/cloud/tasks/v2#CreateTaskRequest + req := &taskspb.CreateTaskRequest{ + Parent: queuePath, + Task: &taskspb.Task{ + // https://godoc.org/google.golang.org/genproto/googleapis/cloud/tasks/v2#HttpRequest + MessageType: &taskspb.Task_HttpRequest{ + HttpRequest: &taskspb.HttpRequest{ + HttpMethod: taskspb.HttpMethod_POST, + Url: url, + }, + }, + }, + } + + // Add a payload message if one is present. + req.Task.GetHttpRequest().Body = []byte(body) + + createdTask, err := client.CreateTask(ctx, req) + if err != nil { + return nil, fmt.Errorf("cloudtasks.CreateTask: %w", err) + } + + return createdTask, nil +} + +// createHTTPTaskWithToken constructs a task with a authorization token +// and HTTP target then adds it to a Queue. +func createHTTPTaskWithToken(projectID, locationID, queueID, url, email, message string) (*taskspb.Task, error) { + // Create a new Cloud Tasks client instance. + // See https://godoc.org/cloud.google.com/go/cloudtasks/apiv2 + ctx := context.Background() + client, err := cloudtasks.NewClient(ctx) + if err != nil { + return nil, fmt.Errorf("NewClient: %w", err) + } + defer client.Close() + + // Build the Task queue path. + queuePath := fmt.Sprintf("projects/%s/locations/%s/queues/%s", projectID, locationID, queueID) + + // Build the Task payload. + // https://godoc.org/google.golang.org/genproto/googleapis/cloud/tasks/v2#CreateTaskRequest + req := &taskspb.CreateTaskRequest{ + Parent: queuePath, + Task: &taskspb.Task{ + // https://godoc.org/google.golang.org/genproto/googleapis/cloud/tasks/v2#HttpRequest + MessageType: &taskspb.Task_HttpRequest{ + HttpRequest: &taskspb.HttpRequest{ + HttpMethod: taskspb.HttpMethod_POST, + Url: url, + AuthorizationHeader: &taskspb.HttpRequest_OidcToken{ + OidcToken: &taskspb.OidcToken{ + ServiceAccountEmail: email, + }, + }, + }, + }, + }, + } + + // Add a payload message if one is present. + req.Task.GetHttpRequest().Body = []byte(message) + + createdTask, err := client.CreateTask(ctx, req) + if err != nil { + return nil, fmt.Errorf("cloudtasks.CreateTask: %w", err) + } + + return createdTask, nil +} diff --git a/api/go.mod b/api/go.mod index 006e9a76..2834589d 100644 --- a/api/go.mod +++ b/api/go.mod @@ -15,29 +15,30 @@ require ( github.com/swaggo/swag v1.16.6 github.com/wneessen/go-mail v0.7.2 go.mongodb.org/mongo-driver v1.17.4 - google.golang.org/api v0.224.0 + google.golang.org/api v0.247.0 ) require ( - cel.dev/expr v0.19.2 // indirect - cloud.google.com/go v0.118.3 // indirect - cloud.google.com/go/auth v0.15.0 // indirect - cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect - cloud.google.com/go/compute/metadata v0.6.0 // indirect - cloud.google.com/go/iam v1.4.1 // indirect - cloud.google.com/go/monitoring v1.24.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 // indirect + cel.dev/expr v0.24.0 // indirect + cloud.google.com/go v0.120.0 // indirect + cloud.google.com/go/auth v0.16.4 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.8.0 // indirect + cloud.google.com/go/iam v1.5.2 // indirect + cloud.google.com/go/monitoring v1.24.2 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 // indirect + github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/go-logr/logr v1.4.2 // indirect + github.com/go-jose/go-jose/v4 v4.0.5 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.22.0 // indirect github.com/go-openapi/jsonreference v0.21.1 // indirect @@ -45,30 +46,33 @@ require ( github.com/go-openapi/swag v0.24.1 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.5 // indirect - github.com/googleapis/gax-go/v2 v2.14.1 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect + github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect + github.com/zeebo/errs v1.4.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.34.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect - go.opentelemetry.io/otel v1.34.0 // indirect - go.opentelemetry.io/otel/metric v1.34.0 // indirect - go.opentelemetry.io/otel/sdk v1.34.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.34.0 // indirect - go.opentelemetry.io/otel/trace v1.34.0 // indirect - golang.org/x/oauth2 v0.28.0 // indirect - golang.org/x/time v0.10.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.36.0 // indirect + go.opentelemetry.io/otel/metric v1.36.0 // indirect + go.opentelemetry.io/otel/sdk v1.36.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.36.0 // indirect + go.opentelemetry.io/otel/trace v1.36.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/time v0.12.0 // indirect golang.org/x/tools v0.38.0 // indirect - google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect - google.golang.org/grpc v1.71.0 // indirect + google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect + google.golang.org/grpc v1.74.2 // indirect ) require ( + cloud.google.com/go/cloudtasks v1.13.7 github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.14.1 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect diff --git a/api/go.sum b/api/go.sum index 3191141c..d3d10cbf 100644 --- a/api/go.sum +++ b/api/go.sum @@ -1,27 +1,29 @@ -cel.dev/expr v0.19.2 h1:V354PbqIXr9IQdwy4SYA4xa0HXaWq1BUPAGzugBY5V4= -cel.dev/expr v0.19.2/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= -cloud.google.com/go v0.118.3 h1:jsypSnrE/w4mJysioGdMBg4MiW/hHx/sArFpaBWHdME= -cloud.google.com/go v0.118.3/go.mod h1:Lhs3YLnBlwJ4KA6nuObNMZ/fCbOQBPuWKPoE0Wa/9Vc= -cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps= -cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8= -cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M= -cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc= -cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= -cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= -cloud.google.com/go/iam v1.4.1 h1:cFC25Nv+u5BkTR/BT1tXdoF2daiVbZ1RLx2eqfQ9RMM= -cloud.google.com/go/iam v1.4.1/go.mod h1:2vUEJpUG3Q9p2UdsyksaKpDzlwOrnMzS30isdReIcLM= +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +cloud.google.com/go v0.120.0 h1:wc6bgG9DHyKqF5/vQvX1CiZrtHnxJjBlKUyF9nP6meA= +cloud.google.com/go v0.120.0/go.mod h1:/beW32s8/pGRuj4IILWQNd4uuebeT4dkOhKmkfit64Q= +cloud.google.com/go/auth v0.16.4 h1:fXOAIQmkApVvcIn7Pc2+5J8QTMVbUGLscnSVNl11su8= +cloud.google.com/go/auth v0.16.4/go.mod h1:j10ncYwjX/g3cdX7GpEzsdM+d+ZNsXAbb6qXA7p1Y5M= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/cloudtasks v1.13.7 h1:H2v8GEolNtMFfYzUpZBaZbydqU7drpyo99GtAgA+m4I= +cloud.google.com/go/cloudtasks v1.13.7/go.mod h1:H0TThOUG+Ml34e2+ZtW6k6nt4i9KuH3nYAJ5mxh7OM4= +cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA= +cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw= +cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= +cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc= cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA= -cloud.google.com/go/longrunning v0.6.5 h1:sD+t8DO8j4HKW4QfouCklg7ZC1qC4uzVZt8iz3uTW+Q= -cloud.google.com/go/longrunning v0.6.5/go.mod h1:Et04XK+0TTLKa5IPYryKf5DkpwImy6TluQ1QTLwlKmY= -cloud.google.com/go/monitoring v1.24.0 h1:csSKiCJ+WVRgNkRzzz3BPoGjFhjPY23ZTcaenToJxMM= -cloud.google.com/go/monitoring v1.24.0/go.mod h1:Bd1PRK5bmQBQNnuGwHBfUamAV1ys9049oEPHnn4pcsc= +cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE= +cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY= +cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM= +cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U= cloud.google.com/go/storage v1.51.0 h1:ZVZ11zCiD7b3k+cH5lQs/qcNaoSz3U9I0jgwVzqDlCw= cloud.google.com/go/storage v1.51.0/go.mod h1:YEJfu/Ki3i5oHC/7jyTgsGZwdQ8P9hqMqvpi5kRKGgc= -cloud.google.com/go/trace v1.11.3 h1:c+I4YFjxRQjvAhRmSsmjpASUKq88chOX854ied0K/pE= -cloud.google.com/go/trace v1.11.3/go.mod h1:pt7zCYiDSQjC9Y2oqCsh9jF4GStB/hmjrYLsxRR27q8= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 h1:3c8yed4lgqTt+oTQ+JNMDo+F4xprBf+O/il4ZC0nRLw= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0/go.mod h1:obipzmGjfSjam60XLwGfqUkJsfiheAl+TUjG+4yzyPM= +cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4= +cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 h1:ErKg/3iS1AKcTkf3yixlZ54f9U1rljCkQyEXWUnIUxc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0/go.mod h1:yAZHSGnqScoU556rBOVkwLze6WP5N+U11RHuWaGVxwY= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0 h1:OqVGm6Ei3x5+yZmSJG1Mh2NwHvpVmZ08CB5qJhT9Nuk= @@ -40,8 +42,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 h1:Om6kYQYDUk5wWbT0t0q6pvyM49i9XZAv9dDrkDA7gjk= -github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls= +github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -69,9 +71,11 @@ github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/jsonpointer v0.22.0 h1:TmMhghgNef9YXxTu1tOopo+0BGEytxA+okbry0HjZsM= @@ -127,10 +131,10 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.5 h1:VgzTY2jogw3xt39CusEnFJWm7rlsq5yL5q9XdLOuP5g= -github.com/googleapis/enterprise-certificate-proxy v0.3.5/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= -github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= -github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= +github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= +github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= +github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -170,6 +174,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= +github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -200,28 +206,30 @@ github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gi github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= +github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw= go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/detectors/gcp v1.34.0 h1:JRxssobiPg23otYU5SbWtQC//snGVIM3Tx6QRzlQBao= -go.opentelemetry.io/contrib/detectors/gcp v1.34.0/go.mod h1:cV4BMFcscUR/ckqLkbfQmF0PRsq8w/lMGzdbCSveBHo= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= -go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= -go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= +go.opentelemetry.io/contrib/detectors/gcp v1.36.0 h1:F7q2tNlCaHY9nMKHR6XH9/qkp8FktLnIcy6jJNyOCQw= +go.opentelemetry.io/contrib/detectors/gcp v1.36.0/go.mod h1:IbBN8uAIIx734PTonTPxAxnjc2pQTxWNkwfstZ+6H2k= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= +go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.29.0 h1:WDdP9acbMYjbKIyJUhTvtzj601sVJOqgWdUxSdR/Ysc= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.29.0/go.mod h1:BLbf7zbNIONBLPwvFnwNHGj4zge8uTCM/UPIVW1Mq2I= -go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= -go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= -go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= -go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= -go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= -go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= -go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= -go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= +go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= +go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= +go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= +go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= +go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= +go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= +go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= +go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/arch v0.21.0 h1:iTC9o7+wP6cPWpDWkivCvQFGAHDQ59SrSxsLPcnkArw= @@ -239,8 +247,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= -golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= @@ -264,24 +272,24 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= -golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= -golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.224.0 h1:Ir4UPtDsNiwIOHdExr3fAj4xZ42QjK7uQte3lORLJwU= -google.golang.org/api v0.224.0/go.mod h1:3V39my2xAGkodXy0vEqcEtkqgw2GtrFL5WuBZlCTCOQ= -google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE= -google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE= -google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= -google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb h1:TLPQVbx1GJ8VKZxz52VAxl1EBgKXXbTiU9Fc5fZeLn4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= -google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= -google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/api v0.247.0 h1:tSd/e0QrUlLsrwMKmkbQhYVa109qIintOls2Wh6bngc= +google.golang.org/api v0.247.0/go.mod h1:r1qZOPmxXffXg6xS5uhx16Fa/UFY8QU/K4bfKrnvovM= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= +google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c h1:AtEkQdl5b6zsybXcbz00j1LwNodDuH6hVifIaNqk7NQ= +google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c/go.mod h1:ea2MjsO70ssTfCjiwHgI0ZFqcw45Ksuk2ckf9G468GA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= +google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= +google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/api/routes/email.go b/api/routes/email.go index 90ca870a..3fb52ff3 100644 --- a/api/routes/email.go +++ b/api/routes/email.go @@ -11,5 +11,6 @@ func EmailRoute(router *gin.Engine) { emailGroup := router.Group("/email") emailGroup.OPTIONS("", controllers.Preflight) - emailGroup.POST("", controllers.SendEmail) + emailGroup.POST("/send", controllers.SendEmail) + emailGroup.POST("/queue", controllers.QueueEmail) } From 92b0161da95e35364b29f8c33e6939b1e86a2747 Mon Sep 17 00:00:00 2001 From: Justin Date: Sun, 1 Mar 2026 21:10:27 -0600 Subject: [PATCH 11/61] go mod tidy after merge --- api/go.mod | 13 ++++++++----- api/go.sum | 14 ++++++++------ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/api/go.mod b/api/go.mod index c858c2e3..41bb1bea 100644 --- a/api/go.mod +++ b/api/go.mod @@ -37,6 +37,7 @@ require ( github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.0.5 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.22.0 // indirect @@ -50,17 +51,19 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect + github.com/zeebo/errs v1.4.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.34.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel/metric v1.40.0 // indirect go.opentelemetry.io/otel/sdk v1.40.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect - golang.org/x/oauth2 v0.28.0 // indirect - golang.org/x/time v0.10.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/time v0.12.0 // indirect golang.org/x/tools v0.38.0 // indirect google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c // indirect diff --git a/api/go.sum b/api/go.sum index 4c87b471..9e7aa9d5 100644 --- a/api/go.sum +++ b/api/go.sum @@ -174,6 +174,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= +github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -210,12 +212,12 @@ go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFX go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/detectors/gcp v1.34.0 h1:JRxssobiPg23otYU5SbWtQC//snGVIM3Tx6QRzlQBao= -go.opentelemetry.io/contrib/detectors/gcp v1.34.0/go.mod h1:cV4BMFcscUR/ckqLkbfQmF0PRsq8w/lMGzdbCSveBHo= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= +go.opentelemetry.io/contrib/detectors/gcp v1.36.0 h1:F7q2tNlCaHY9nMKHR6XH9/qkp8FktLnIcy6jJNyOCQw= +go.opentelemetry.io/contrib/detectors/gcp v1.36.0/go.mod h1:IbBN8uAIIx734PTonTPxAxnjc2pQTxWNkwfstZ+6H2k= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.29.0 h1:WDdP9acbMYjbKIyJUhTvtzj601sVJOqgWdUxSdR/Ysc= From 47fe494d6b255b81c2eafc0c60ff623c83ac93dd Mon Sep 17 00:00:00 2001 From: Justin Date: Sun, 1 Mar 2026 21:32:32 -0600 Subject: [PATCH 12/61] Add basic email route similar to storage.go --- api/routes/email.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/api/routes/email.go b/api/routes/email.go index 3fb52ff3..79b6eb25 100644 --- a/api/routes/email.go +++ b/api/routes/email.go @@ -1,15 +1,33 @@ package routes import ( + "net/http" + "os" + "github.com/gin-gonic/gin" "github.com/UTDNebula/nebula-api/api/controllers" + "github.com/UTDNebula/nebula-api/api/schema" ) func EmailRoute(router *gin.Engine) { + // Rescrict with password + authMiddleware := func(c *gin.Context) { + secret := c.GetHeader("x-email-key") + expected, exist := os.LookupEnv("EMAIL_ROUTE_KEY") + if !exist || secret != expected { + c.AbortWithStatusJSON(http.StatusForbidden, schema.APIResponse[string]{Status: http.StatusForbidden, Message: "error", Data: "Forbidden"}) + return + } + c.Next() + } + // All routes related to email come here emailGroup := router.Group("/email") + // Use auth + emailGroup.Use(authMiddleware) + emailGroup.OPTIONS("", controllers.Preflight) emailGroup.POST("/send", controllers.SendEmail) emailGroup.POST("/queue", controllers.QueueEmail) From 6d8ceee804684065a12baa40e10cd7fe0bebefbb Mon Sep 17 00:00:00 2001 From: Justin Date: Sun, 1 Mar 2026 23:57:49 -0600 Subject: [PATCH 13/61] Use singletons --- api/controllers/email.go | 161 ++++++++++++++------------------------- api/routes/email.go | 92 ++++++++++++++++++++++ 2 files changed, 149 insertions(+), 104 deletions(-) diff --git a/api/controllers/email.go b/api/controllers/email.go index d296270c..207a1152 100644 --- a/api/controllers/email.go +++ b/api/controllers/email.go @@ -1,10 +1,7 @@ package controllers import ( - "context" - "fmt" "net/http" - "os" _ "github.com/UTDNebula/nebula-api/api/schema" "github.com/gin-gonic/gin" @@ -21,6 +18,51 @@ type EmailRequest struct { Body string `json:"body" binding:"required"` } +// Get email client from routes +func getEmailClient(c *gin.Context) *mail.Client { + val, exists := c.Get("emailClient") + if !exists { + panic("email client not set in context") + } + return val.(*mail.Client) +} + +// Get email from address from routes +func getEmailFrom(c *gin.Context) string { + val, exists := c.Get("emailFrom") + if !exists { + panic("email from address not set in context") + } + return val.(string) +} + +// Get cloud tasks client from routes +func getTasksClient(c *gin.Context) *cloudtasks.Client { + val, exists := c.Get("tasksClient") + if !exists { + panic("tasks client not set in context") + } + return val.(*cloudtasks.Client) +} + +// Get queue path from routes +func getQueuePath(c *gin.Context) string { + val, exists := c.Get("queuePath") + if !exists { + panic("queue path not set in context") + } + return val.(string) +} + +// Get queue url from routes +func getQueueUrl(c *gin.Context) string { + val, exists := c.Get("queueUrl") + if !exists { + panic("queue url not set in context") + } + return val.(string) +} + // @Id sendEmail // @Router /email [post] // @Description "Send an email via SMTP" @@ -31,7 +73,6 @@ type EmailRequest struct { // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 400 {object} schema.APIResponse[string] "A string describing the error" func SendEmail(c *gin.Context) { - var req EmailRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -39,15 +80,8 @@ func SendEmail(c *gin.Context) { return } - smtpHost := os.Getenv("SMTP_HOST") // TODO: We should be using env.go for this - smtpUser := os.Getenv("SMTP_USERNAME") - smtpPass := os.Getenv("SMTP_PASSWORD") - smtpFrom := os.Getenv("SMTP_FROM") - - if smtpHost == "" || smtpFrom == "" { - respond(c, http.StatusInternalServerError, "SMTP configuration is missing", "SMTP environment variables are not fully configured") - return - } + client := getEmailClient(c) + smtpFrom := getEmailFrom(c) m := mail.NewMsg() if err := m.FromFormat(req.From, smtpFrom); err != nil { @@ -63,19 +97,6 @@ func SendEmail(c *gin.Context) { m.Subject(req.Subject) m.SetBodyString(mail.TypeTextPlain, req.Body) - client, err := mail.NewClient( - smtpHost, - mail.WithTLSPortPolicy(mail.TLSMandatory), - mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover), - mail.WithUsername(smtpUser), - mail.WithPassword(smtpPass), - ) - - if err != nil { - respond(c, http.StatusInternalServerError, "failed to setup SMTP client", err.Error()) - return - } - if err := client.DialAndSend(m); err != nil { respond(c, http.StatusInternalServerError, "failed to send email", err.Error()) return @@ -85,7 +106,6 @@ func SendEmail(c *gin.Context) { } func QueueEmail(c *gin.Context) { - // Request must be able to bind to email request if err := c.ShouldBindJSON(&EmailRequest{}); err != nil { respond(c, http.StatusBadRequest, "invalid request payload", err.Error()) @@ -95,42 +115,19 @@ func QueueEmail(c *gin.Context) { var body []byte c.Request.Body.Read(body) - queuePath := os.Getenv("QUEUE_PATH") - url := os.Getenv("EMAIL_URL") // TODO: Consider a different name - - _, err := createHTTPTask(queuePath, url, body) - - if err != nil { - respond(c, http.StatusInternalServerError, "failed to queue email", err.Error()) - return - } - - respond(c, http.StatusOK, "success", "Email queued successfully") // TODO: Change the response - -} - -// createHTTPTask creates a new task with a HTTP target then adds it to a Queue. -func createHTTPTask(queuePath string, url string, body []byte) (*taskspb.Task, error) { - - // Create a new Cloud Tasks client instance. - // See https://godoc.org/cloud.google.com/go/cloudtasks/apiv2 - ctx := context.Background() - client, err := cloudtasks.NewClient(ctx) - if err != nil { - return nil, fmt.Errorf("NewClient: %w", err) - } - defer client.Close() + client := getTasksClient(c) + queuePath := getQueuePath(c) + queueUrl := getQueueUrl(c) // Build the Task payload. - // https://godoc.org/google.golang.org/genproto/googleapis/cloud/tasks/v2#CreateTaskRequest + // https://docs.cloud.google.com/tasks/docs/creating-http-target-tasks req := &taskspb.CreateTaskRequest{ Parent: queuePath, Task: &taskspb.Task{ - // https://godoc.org/google.golang.org/genproto/googleapis/cloud/tasks/v2#HttpRequest MessageType: &taskspb.Task_HttpRequest{ HttpRequest: &taskspb.HttpRequest{ HttpMethod: taskspb.HttpMethod_POST, - Url: url, + Url: queueUrl, }, }, }, @@ -139,56 +136,12 @@ func createHTTPTask(queuePath string, url string, body []byte) (*taskspb.Task, e // Add a payload message if one is present. req.Task.GetHttpRequest().Body = []byte(body) - createdTask, err := client.CreateTask(ctx, req) - if err != nil { - return nil, fmt.Errorf("cloudtasks.CreateTask: %w", err) - } - - return createdTask, nil -} - -// createHTTPTaskWithToken constructs a task with a authorization token -// and HTTP target then adds it to a Queue. -func createHTTPTaskWithToken(projectID, locationID, queueID, url, email, message string) (*taskspb.Task, error) { - // Create a new Cloud Tasks client instance. - // See https://godoc.org/cloud.google.com/go/cloudtasks/apiv2 - ctx := context.Background() - client, err := cloudtasks.NewClient(ctx) + _, err := client.CreateTask(c.Request.Context(), req) if err != nil { - return nil, fmt.Errorf("NewClient: %w", err) - } - defer client.Close() - - // Build the Task queue path. - queuePath := fmt.Sprintf("projects/%s/locations/%s/queues/%s", projectID, locationID, queueID) - - // Build the Task payload. - // https://godoc.org/google.golang.org/genproto/googleapis/cloud/tasks/v2#CreateTaskRequest - req := &taskspb.CreateTaskRequest{ - Parent: queuePath, - Task: &taskspb.Task{ - // https://godoc.org/google.golang.org/genproto/googleapis/cloud/tasks/v2#HttpRequest - MessageType: &taskspb.Task_HttpRequest{ - HttpRequest: &taskspb.HttpRequest{ - HttpMethod: taskspb.HttpMethod_POST, - Url: url, - AuthorizationHeader: &taskspb.HttpRequest_OidcToken{ - OidcToken: &taskspb.OidcToken{ - ServiceAccountEmail: email, - }, - }, - }, - }, - }, + respond(c, http.StatusInternalServerError, "failed to queue email", err.Error()) + return } - // Add a payload message if one is present. - req.Task.GetHttpRequest().Body = []byte(message) - - createdTask, err := client.CreateTask(ctx, req) - if err != nil { - return nil, fmt.Errorf("cloudtasks.CreateTask: %w", err) - } + respond(c, http.StatusOK, "success", "Email queued successfully") // TODO: Change the response - return createdTask, nil -} +} \ No newline at end of file diff --git a/api/routes/email.go b/api/routes/email.go index 79b6eb25..463e7327 100644 --- a/api/routes/email.go +++ b/api/routes/email.go @@ -1,16 +1,98 @@ package routes import ( + "context" + "log" "net/http" "os" + "sync" + cloudtasks "cloud.google.com/go/cloudtasks/apiv2" "github.com/gin-gonic/gin" + "github.com/wneessen/go-mail" "github.com/UTDNebula/nebula-api/api/controllers" "github.com/UTDNebula/nebula-api/api/schema" ) +var emailClient *mail.Client +var smtpFromAddr string +var emailClientOnce sync.Once + +var tasksClient *cloudtasks.Client +var queuePath string +var queueUrl string +var tasksClientOnce sync.Once + +func initTasksClient() (*cloudtasks.Client, string, string) { + tasksClientOnce.Do(func() { + qPath := os.Getenv("QUEUE_PATH") + qUrl := os.Getenv("EMAIL_URL") // TODO: Consider a different name + + if qPath == "" || qUrl == "" { + log.Println("Cloud Tasks environment variables are not fully configured; skipping email queuing routes") + return + } + + ctx := context.Background() + c, err := cloudtasks.NewClient(ctx) + if err != nil { + log.Printf("Failed to create Cloud Tasks client: %v", err) + return + } + tasksClient = c + queuePath = qPath + queueUrl = qUrl + }) + return tasksClient, queuePath, queueUrl +} + +func initEmailClient() (*mail.Client, string) { + emailClientOnce.Do(func() { + smtpHost := os.Getenv("SMTP_HOST") // TODO: use lookupenv instead + smtpUser := os.Getenv("SMTP_USERNAME") + smtpPass := os.Getenv("SMTP_PASSWORD") + smtpFrom := os.Getenv("SMTP_FROM") + + if smtpHost == "" || smtpUser == "" || smtpPass == "" || smtpFrom == "" { + log.Println("SMTP environment variables are not fully configured; skipping email routes") + return + } + + c, err := mail.NewClient( + smtpHost, + mail.WithTLSPortPolicy(mail.TLSMandatory), + mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover), + mail.WithUsername(smtpUser), + mail.WithPassword(smtpPass), + ) + if err != nil { + log.Printf("Failed to create SMTP client: %v", err) + return + } + emailClient = c + smtpFromAddr = smtpFrom + }) + return emailClient, smtpFromAddr +} + func EmailRoute(router *gin.Engine) { + client, fromAddr := initEmailClient() + tClient, qPath, qUrl := initTasksClient() + + if client == nil { + log.Println("SMTP client not initialized") + } + + if tClient == nil { + log.Println("Cloud Tasks client not initialized") + } + + if client == nil || tClient == nil { + log.Println("skipping email routes") + return + } + // Rescrict with password authMiddleware := func(c *gin.Context) { secret := c.GetHeader("x-email-key") @@ -25,6 +107,16 @@ func EmailRoute(router *gin.Engine) { // All routes related to email come here emailGroup := router.Group("/email") + // Pass to next layer + emailGroup.Use(func(c *gin.Context) { + c.Set("emailClient", client) + c.Set("emailFrom", fromAddr) + c.Set("tasksClient", tClient) + c.Set("queuePath", qPath) + c.Set("queueUrl", qUrl) + c.Next() + }) + // Use auth emailGroup.Use(authMiddleware) From e5df25fe79db3c786b0edf6895fde1be84c6a76e Mon Sep 17 00:00:00 2001 From: Justin Date: Mon, 2 Mar 2026 00:36:45 -0600 Subject: [PATCH 14/61] Update env template and readme for new env variables --- README.md | 17 +++++++++++++---- api/.env.template | 19 ++++++++++++++++--- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index bc8cb372..b5283aaf 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Project maintained by [Nebula Labs](https://about.utdnebula.com). Documentation for the current production API can be found [here.](https://api.utdnebula.com/swagger/index.html) -## How to use +## How to use Nebula API in your own project - Visit our [Discord](https://discord.utdnebula.com) and ask to be provisioned an API key (please provide details on your use case) - Read the documentation listed above (and authenticate with your key for interactive demos) @@ -21,7 +21,8 @@ Contributions are welcome! This project uses the MIT License. -Please visit our [Discord](https://discord.utdnebula.com) and talk to us if you'd like to contribute! +Please visit our [Discord](https://discord.utdnebula.com) and talk to us if you'd like to contribute! Don't be afraid to ask for help! + ### How to Contribute Create your own fork by [forking this repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo#forking-a-repository) @@ -43,6 +44,8 @@ Setup Go Dependencies with Build with `.\build.bat build` +This will create an executable named `go-api.exe` in the `api` directory + Run with `.\go-api.exe` > Note: some have experienced issues with Windows Defender or other antivirus blocking `go-api.exe` from reading files, editing files, or causing slowed performance. Consider adding a exception to your `nebula-api` folder. @@ -56,17 +59,23 @@ Setup Go dependencies with Build with `make build` +This will create an executable named `go-api` in the `api` directory + +> Note: If Make fails with "swag: No such file or directory" or similar, you may need to add GOPATH/bin to your path to your path, On Mac, use ``echo 'export PATH=${PATH}:`go env GOPATH`/bin' >> ~/.zshrc && source ~/.zshrc`` to add it permanently to your .zshrc + Run with `./go-api` -## Running to API locally +## Running API locally Copy `.env.template` to `.env` with `cp .env.template .env` -Enter Nebula MongoDB URI in `.env` +Enter Nebula MongoDB URI in `.env` (ask for help in the [Discord](https://discord.utdnebula.com)) Run `go-api` Check command output to see the route serving traffic. It's likely port 8080 Visit `http://localhost:8080` to access nebula-api locally + +> Storage and email routes require additional environment variables. If you're working on these routes, ask for help in the [Discord](https://discord.utdnebula.com) diff --git a/api/.env.template b/api/.env.template index 5ed5fe27..768b0bb9 100644 --- a/api/.env.template +++ b/api/.env.template @@ -1,3 +1,5 @@ +# See README.MD for more information + # DATABASE URI MONGODB_URI= @@ -8,9 +10,20 @@ MONGODB_URI= #PORT= #GIN_MODE=release -# CLOUD STORAGE (internal use only) +# SENRTY +SENTRY_ENVIRONMENT=development + +# For Google Cloud Routes /storage /email (internal use only) GOOGLE_APPLICATION_CREDENTIALS= STORAGE_ROUTE_KEY= +EMAIL_ROUTE_KEY= -# SENRTY -SENTRY_ENVIRONMENT=development +# For SMTP email sending +SMTP_HOST=smtp.gmail.com +SMTP_USERNAME= +SMTP_FROM= +SMTP_PASSWORD= + +# For Email Google Cloud Task Queueing +GCLOUD_EMAIL_QUEUE_PATH= +GCLOUD_EMAIL_QUEUE_URL= From 2e72636ae453d1b7a46b9ce85928dc73002e719d Mon Sep 17 00:00:00 2001 From: Justin Date: Mon, 2 Mar 2026 12:20:20 -0600 Subject: [PATCH 15/61] Split email route keys --- api/.env.template | 3 ++- api/routes/email.go | 31 ++++++++++++++++--------------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/api/.env.template b/api/.env.template index 768b0bb9..88588424 100644 --- a/api/.env.template +++ b/api/.env.template @@ -16,7 +16,8 @@ SENTRY_ENVIRONMENT=development # For Google Cloud Routes /storage /email (internal use only) GOOGLE_APPLICATION_CREDENTIALS= STORAGE_ROUTE_KEY= -EMAIL_ROUTE_KEY= +EMAIL_SEND_ROUTE_KEY= +EMAIL_QUEUE_ROUTE_KEY= # For SMTP email sending SMTP_HOST=smtp.gmail.com diff --git a/api/routes/email.go b/api/routes/email.go index 463e7327..65c029cb 100644 --- a/api/routes/email.go +++ b/api/routes/email.go @@ -25,9 +25,10 @@ var queueUrl string var tasksClientOnce sync.Once func initTasksClient() (*cloudtasks.Client, string, string) { + // Singleton to prevent multiple clients tasksClientOnce.Do(func() { - qPath := os.Getenv("QUEUE_PATH") - qUrl := os.Getenv("EMAIL_URL") // TODO: Consider a different name + qPath := os.Getenv("GCLOUD_EMAIL_QUEUE_PATH") + qUrl := os.Getenv("GCLOUD_EMAIL_QUEUE_URL") if qPath == "" || qUrl == "" { log.Println("Cloud Tasks environment variables are not fully configured; skipping email queuing routes") @@ -48,6 +49,7 @@ func initTasksClient() (*cloudtasks.Client, string, string) { } func initEmailClient() (*mail.Client, string) { + // Singleton to prevent multiple clients emailClientOnce.Do(func() { smtpHost := os.Getenv("SMTP_HOST") // TODO: use lookupenv instead smtpUser := os.Getenv("SMTP_USERNAME") @@ -93,15 +95,17 @@ func EmailRoute(router *gin.Engine) { return } - // Rescrict with password - authMiddleware := func(c *gin.Context) { - secret := c.GetHeader("x-email-key") - expected, exist := os.LookupEnv("EMAIL_ROUTE_KEY") - if !exist || secret != expected { - c.AbortWithStatusJSON(http.StatusForbidden, schema.APIResponse[string]{Status: http.StatusForbidden, Message: "error", Data: "Forbidden"}) - return + // Restrict with password + authMiddleware := func(key string, envKey string) gin.HandlerFunc { + return func(c *gin.Context) { + secret := c.GetHeader(key) + expected, exist := os.LookupEnv(envKey) + if !exist || secret != expected { + c.AbortWithStatusJSON(http.StatusForbidden, schema.APIResponse[string]{Status: http.StatusForbidden, Message: "error", Data: "Forbidden"}) + return + } + c.Next() } - c.Next() } // All routes related to email come here @@ -117,10 +121,7 @@ func EmailRoute(router *gin.Engine) { c.Next() }) - // Use auth - emailGroup.Use(authMiddleware) - emailGroup.OPTIONS("", controllers.Preflight) - emailGroup.POST("/send", controllers.SendEmail) - emailGroup.POST("/queue", controllers.QueueEmail) + emailGroup.POST("/send", authMiddleware("x-email-send-key", "EMAIL_SEND_ROUTE_KEY"), controllers.SendEmail) + emailGroup.POST("/queue", authMiddleware("x-email-queue-key", "EMAIL_QUEUE_ROUTE_KEY"), controllers.QueueEmail) } From acbb30a7e22c7fbfba059a8748dbf71dc3f3463e Mon Sep 17 00:00:00 2001 From: Justin Date: Mon, 2 Mar 2026 14:09:24 -0600 Subject: [PATCH 16/61] Update swaggo docs --- api/controllers/email.go | 25 ++++++++++++++++++------- api/docs/docs.go | 13 ++++++++++--- api/docs/swagger.yaml | 12 +++++++++--- 3 files changed, 37 insertions(+), 13 deletions(-) diff --git a/api/controllers/email.go b/api/controllers/email.go index 207a1152..d0b1fa66 100644 --- a/api/controllers/email.go +++ b/api/controllers/email.go @@ -11,6 +11,7 @@ import ( taskspb "cloud.google.com/go/cloudtasks/apiv2/cloudtaskspb" ) +// TODO: This should be in schema type EmailRequest struct { From string `json:"from" binding:"required"` To string `json:"to" binding:"required,email"` @@ -65,13 +66,14 @@ func getQueueUrl(c *gin.Context) string { // @Id sendEmail // @Router /email [post] -// @Description "Send an email via SMTP" +// @Description "Send an email via SMTP. This route is restricted to only Nebula Labs internal Projects." // @Accept json // @Produce json -// @Param request body EmailRequest true "Email Request Body" -// @Success 200 {object} schema.APIResponse[string] "Email sent successfully" -// @Failure 500 {object} schema.APIResponse[string] "A string describing the error" -// @Failure 400 {object} schema.APIResponse[string] "A string describing the error" +// @Param request body EmailRequest true "Email Request Body" +// @Param x-email-send-key header string true "The internal email send key" +// @Success 200 {object} schema.APIResponse[string] "Email sent successfully" +// @Failure 500 {object} schema.APIResponse[string] "A string describing the error" +// @Failure 400 {object} schema.APIResponse[string] "A string describing the error" func SendEmail(c *gin.Context) { var req EmailRequest @@ -105,6 +107,16 @@ func SendEmail(c *gin.Context) { respond(c, http.StatusOK, "success", "Email sent successfully") } +// @Id QueueEmail +// @Router /email [post] +// @Description "Queue an email to be sent via SMTP. This route is restricted to only Nebula Labs internal Projects." +// @Accept json +// @Produce json +// @Param request body EmailRequest true "Email Request Body" +// @Param x-email-queue-key header string true "The internal email queue key" +// @Success 200 {object} schema.APIResponse[string] "Email queued successfully" +// @Failure 500 {object} schema.APIResponse[string] "A string describing the error" +// @Failure 400 {object} schema.APIResponse[string] "A string describing the error" func QueueEmail(c *gin.Context) { // Request must be able to bind to email request if err := c.ShouldBindJSON(&EmailRequest{}); err != nil { @@ -143,5 +155,4 @@ func QueueEmail(c *gin.Context) { } respond(c, http.StatusOK, "success", "Email queued successfully") // TODO: Change the response - -} \ No newline at end of file +} diff --git a/api/docs/docs.go b/api/docs/docs.go index fe590ab5..59ed610f 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -893,14 +893,14 @@ const docTemplate = `{ }, "/email": { "post": { - "description": "\"Send an email via SMTP\"", + "description": "\"Queue an email to be sent via SMTP. This route is restricted to only Nebula Labs internal Projects.\"", "consumes": [ "application/json" ], "produces": [ "application/json" ], - "operationId": "sendEmail", + "operationId": "QueueEmail", "parameters": [ { "description": "Email Request Body", @@ -910,11 +910,18 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/controllers.EmailRequest" } + }, + { + "type": "string", + "description": "The internal email queue key", + "name": "x-email-queue-key", + "in": "header", + "required": true } ], "responses": { "200": { - "description": "Email sent successfully", + "description": "Email queued successfully", "schema": { "$ref": "#/definitions/schema.APIResponse-string" } diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index 65969c3f..036d1cae 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -1453,8 +1453,9 @@ paths: post: consumes: - application/json - description: '"Send an email via SMTP"' - operationId: sendEmail + description: '"Queue an email to be sent via SMTP. This route is restricted + to only Nebula Labs internal Projects."' + operationId: QueueEmail parameters: - description: Email Request Body in: body @@ -1462,11 +1463,16 @@ paths: required: true schema: $ref: '#/definitions/controllers.EmailRequest' + - description: The internal email queue key + in: header + name: x-email-queue-key + required: true + type: string produces: - application/json responses: "200": - description: Email sent successfully + description: Email queued successfully schema: $ref: '#/definitions/schema.APIResponse-string' "400": From c2f274ab12ee6e10d8ccc989de667a019807c5c8 Mon Sep 17 00:00:00 2001 From: Justin Date: Mon, 2 Mar 2026 14:47:52 -0600 Subject: [PATCH 17/61] Move EmailRequest to schema. Still not sure on the schema --- api/controllers/email.go | 55 +++++++++--------- api/docs/docs.go | 120 ++++++++++++++++++++++++++++++--------- api/docs/swagger.yaml | 84 ++++++++++++++++++++------- api/schema/objects.go | 9 +++ 4 files changed, 195 insertions(+), 73 deletions(-) diff --git a/api/controllers/email.go b/api/controllers/email.go index d0b1fa66..adb016c5 100644 --- a/api/controllers/email.go +++ b/api/controllers/email.go @@ -1,9 +1,10 @@ package controllers import ( + "encoding/json" "net/http" - _ "github.com/UTDNebula/nebula-api/api/schema" + "github.com/UTDNebula/nebula-api/api/schema" "github.com/gin-gonic/gin" "github.com/wneessen/go-mail" @@ -11,14 +12,6 @@ import ( taskspb "cloud.google.com/go/cloudtasks/apiv2/cloudtaskspb" ) -// TODO: This should be in schema -type EmailRequest struct { - From string `json:"from" binding:"required"` - To string `json:"to" binding:"required,email"` - Subject string `json:"subject" binding:"required"` - Body string `json:"body" binding:"required"` -} - // Get email client from routes func getEmailClient(c *gin.Context) *mail.Client { val, exists := c.Get("emailClient") @@ -65,17 +58,17 @@ func getQueueUrl(c *gin.Context) string { } // @Id sendEmail -// @Router /email [post] +// @Router /email/send [post] // @Description "Send an email via SMTP. This route is restricted to only Nebula Labs internal Projects." // @Accept json // @Produce json -// @Param request body EmailRequest true "Email Request Body" -// @Param x-email-send-key header string true "The internal email send key" -// @Success 200 {object} schema.APIResponse[string] "Email sent successfully" -// @Failure 500 {object} schema.APIResponse[string] "A string describing the error" -// @Failure 400 {object} schema.APIResponse[string] "A string describing the error" +// @Param request body schema.EmailRequest true "Email Request Body" +// @Param x-email-send-key header string true "The internal email send key" +// @Success 200 {object} schema.APIResponse[schema.EmailRequest] "Email Request Body" +// @Failure 500 {object} schema.APIResponse[string] "A string describing the error" +// @Failure 400 {object} schema.APIResponse[string] "A string describing the error" func SendEmail(c *gin.Context) { - var req EmailRequest + var req schema.EmailRequest if err := c.ShouldBindJSON(&req); err != nil { respond(c, http.StatusBadRequest, "invalid request payload", err.Error()) @@ -104,28 +97,32 @@ func SendEmail(c *gin.Context) { return } - respond(c, http.StatusOK, "success", "Email sent successfully") + respond(c, http.StatusOK, "success", req) } // @Id QueueEmail -// @Router /email [post] +// @Router /email/queue [post] // @Description "Queue an email to be sent via SMTP. This route is restricted to only Nebula Labs internal Projects." // @Accept json // @Produce json -// @Param request body EmailRequest true "Email Request Body" -// @Param x-email-queue-key header string true "The internal email queue key" -// @Success 200 {object} schema.APIResponse[string] "Email queued successfully" -// @Failure 500 {object} schema.APIResponse[string] "A string describing the error" -// @Failure 400 {object} schema.APIResponse[string] "A string describing the error" +// @Param request body schema.EmailRequest true "Email Request Body" +// @Param x-email-queue-key header string true "The internal email queue key" +// @Success 200 {object} schema.APIResponse[schema.EmailRequest] "Email Request Body with Queued Task Name" +// @Failure 500 {object} schema.APIResponse[string] "A string describing the error" +// @Failure 400 {object} schema.APIResponse[string] "A string describing the error" func QueueEmail(c *gin.Context) { // Request must be able to bind to email request - if err := c.ShouldBindJSON(&EmailRequest{}); err != nil { + var emailReq schema.EmailRequest + if err := c.ShouldBindJSON(&emailReq); err != nil { respond(c, http.StatusBadRequest, "invalid request payload", err.Error()) return } - var body []byte - c.Request.Body.Read(body) + body, err := json.Marshal(emailReq) + if err != nil { + respond(c, http.StatusInternalServerError, "failed to serialize email request", err.Error()) + return + } client := getTasksClient(c) queuePath := getQueuePath(c) @@ -148,11 +145,13 @@ func QueueEmail(c *gin.Context) { // Add a payload message if one is present. req.Task.GetHttpRequest().Body = []byte(body) - _, err := client.CreateTask(c.Request.Context(), req) + task, err := client.CreateTask(c.Request.Context(), req) if err != nil { respond(c, http.StatusInternalServerError, "failed to queue email", err.Error()) return } - respond(c, http.StatusOK, "success", "Email queued successfully") // TODO: Change the response + emailReq.TaskName = task.GetName() + + respond(c, http.StatusOK, "success", emailReq) } diff --git a/api/docs/docs.go b/api/docs/docs.go index 59ed610f..4cad2c5b 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -891,7 +891,7 @@ const docTemplate = `{ } } }, - "/email": { + "/email/queue": { "post": { "description": "\"Queue an email to be sent via SMTP. This route is restricted to only Nebula Labs internal Projects.\"", "consumes": [ @@ -908,7 +908,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/controllers.EmailRequest" + "$ref": "#/definitions/schema.EmailRequest" } }, { @@ -921,11 +921,61 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "Email queued successfully", + "description": "Email Request Body with Queued Task Name", + "schema": { + "$ref": "#/definitions/schema.APIResponse-schema_EmailRequest" + } + }, + "400": { + "description": "A string describing the error", "schema": { "$ref": "#/definitions/schema.APIResponse-string" } }, + "500": { + "description": "A string describing the error", + "schema": { + "$ref": "#/definitions/schema.APIResponse-string" + } + } + } + } + }, + "/email/send": { + "post": { + "description": "\"Send an email via SMTP. This route is restricted to only Nebula Labs internal Projects.\"", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "operationId": "sendEmail", + "parameters": [ + { + "description": "Email Request Body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.EmailRequest" + } + }, + { + "type": "string", + "description": "The internal email send key", + "name": "x-email-send-key", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "Email Request Body", + "schema": { + "$ref": "#/definitions/schema.APIResponse-schema_EmailRequest" + } + }, "400": { "description": "A string describing the error", "schema": { @@ -3070,29 +3120,6 @@ const docTemplate = `{ } }, "definitions": { - "controllers.EmailRequest": { - "type": "object", - "required": [ - "body", - "from", - "subject", - "to" - ], - "properties": { - "body": { - "type": "string" - }, - "from": { - "type": "string" - }, - "subject": { - "type": "string" - }, - "to": { - "type": "string" - } - } - }, "schema.APIResponse-array_int": { "type": "object", "properties": { @@ -3271,6 +3298,20 @@ const docTemplate = `{ } } }, + "schema.APIResponse-schema_EmailRequest": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.EmailRequest" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + } + }, "schema.APIResponse-schema_MultiBuildingEvents-schema_AstraEvent": { "type": "object", "properties": { @@ -3757,6 +3798,33 @@ const docTemplate = `{ } } }, + "schema.EmailRequest": { + "type": "object", + "required": [ + "body", + "from", + "subject", + "to" + ], + "properties": { + "body": { + "type": "string" + }, + "from": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "task_name": { + "description": "Included if queued via Cloud Tasks", + "type": "string" + }, + "to": { + "type": "string" + } + } + }, "schema.Event": { "type": "object", "properties": { diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index 036d1cae..971b89c4 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -1,20 +1,4 @@ definitions: - controllers.EmailRequest: - properties: - body: - type: string - from: - type: string - subject: - type: string - to: - type: string - required: - - body - - from - - subject - - to - type: object schema.APIResponse-array_int: properties: data: @@ -130,6 +114,15 @@ definitions: status: type: integer type: object + schema.APIResponse-schema_EmailRequest: + properties: + data: + $ref: '#/definitions/schema.EmailRequest' + message: + type: string + status: + type: integer + type: object schema.APIResponse-schema_MultiBuildingEvents-schema_AstraEvent: properties: data: @@ -446,6 +439,25 @@ definitions: title: type: string type: object + schema.EmailRequest: + properties: + body: + type: string + from: + type: string + subject: + type: string + task_name: + description: Included if queued via Cloud Tasks + type: string + to: + type: string + required: + - body + - from + - subject + - to + type: object schema.Event: properties: _id: @@ -1449,7 +1461,7 @@ paths: $ref: '#/definitions/schema.APIResponse-string' tags: - Courses - /email: + /email/queue: post: consumes: - application/json @@ -1462,7 +1474,7 @@ paths: name: request required: true schema: - $ref: '#/definitions/controllers.EmailRequest' + $ref: '#/definitions/schema.EmailRequest' - description: The internal email queue key in: header name: x-email-queue-key @@ -1472,9 +1484,43 @@ paths: - application/json responses: "200": - description: Email queued successfully + description: Email Request Body with Queued Task Name + schema: + $ref: '#/definitions/schema.APIResponse-schema_EmailRequest' + "400": + description: A string describing the error + schema: + $ref: '#/definitions/schema.APIResponse-string' + "500": + description: A string describing the error schema: $ref: '#/definitions/schema.APIResponse-string' + /email/send: + post: + consumes: + - application/json + description: '"Send an email via SMTP. This route is restricted to only Nebula + Labs internal Projects."' + operationId: sendEmail + parameters: + - description: Email Request Body + in: body + name: request + required: true + schema: + $ref: '#/definitions/schema.EmailRequest' + - description: The internal email send key + in: header + name: x-email-send-key + required: true + type: string + produces: + - application/json + responses: + "200": + description: Email Request Body + schema: + $ref: '#/definitions/schema.APIResponse-schema_EmailRequest' "400": description: A string describing the error schema: diff --git a/api/schema/objects.go b/api/schema/objects.go index 81694033..74c64470 100644 --- a/api/schema/objects.go +++ b/api/schema/objects.go @@ -334,6 +334,15 @@ type Degree struct { JointProgram bool `bson:"joint_program" json:"joint_program"` } +// Email Request Body +type EmailRequest struct { + From string `json:"from" binding:"required"` + To string `json:"to" binding:"required,email"` + Subject string `json:"subject" binding:"required"` + Body string `json:"body" binding:"required"` + TaskName string `json:"task_name,omitempty"` // Included if queued via Cloud Tasks +} + // Type for all API responses type APIResponse[T any] struct { Status int `json:"status"` From a9f4ac3c446fde325cbe0f492c97063c91b7cd99 Mon Sep 17 00:00:00 2001 From: Justin Date: Fri, 6 Mar 2026 12:58:22 -0600 Subject: [PATCH 18/61] Add embed support --- api/controllers/email.go | 21 ++++++++++++++------- api/docs/docs.go | 35 ++++++++++++++++++++++++++++++----- api/docs/swagger.yaml | 24 ++++++++++++++++++++---- api/schema/objects.go | 17 +++++++++++------ 4 files changed, 75 insertions(+), 22 deletions(-) diff --git a/api/controllers/email.go b/api/controllers/email.go index adb016c5..e866f170 100644 --- a/api/controllers/email.go +++ b/api/controllers/email.go @@ -1,6 +1,7 @@ package controllers import ( + "bytes" "encoding/json" "net/http" @@ -90,7 +91,15 @@ func SendEmail(c *gin.Context) { } m.Subject(req.Subject) - m.SetBodyString(mail.TypeTextPlain, req.Body) + m.SetBodyString(mail.TypeTextHTML, req.Body) + + for _, att := range req.Attachments { + m.AttachReader(att.Name, bytes.NewReader(att.Data)) + } + + for _, emb := range req.Embeds { + m.EmbedReader(emb.Name, bytes.NewReader(emb.Data), mail.WithFileContentID(emb.Name)) + } if err := client.DialAndSend(m); err != nil { respond(c, http.StatusInternalServerError, "failed to send email", err.Error()) @@ -130,7 +139,7 @@ func QueueEmail(c *gin.Context) { // Build the Task payload. // https://docs.cloud.google.com/tasks/docs/creating-http-target-tasks - req := &taskspb.CreateTaskRequest{ + taskReq := &taskspb.CreateTaskRequest{ Parent: queuePath, Task: &taskspb.Task{ MessageType: &taskspb.Task_HttpRequest{ @@ -143,15 +152,13 @@ func QueueEmail(c *gin.Context) { } // Add a payload message if one is present. - req.Task.GetHttpRequest().Body = []byte(body) + taskReq.Task.GetHttpRequest().Body = []byte(body) - task, err := client.CreateTask(c.Request.Context(), req) + task, err := client.CreateTask(c.Request.Context(), taskReq) if err != nil { respond(c, http.StatusInternalServerError, "failed to queue email", err.Error()) return } - emailReq.TaskName = task.GetName() - - respond(c, http.StatusOK, "success", emailReq) + respond(c, http.StatusOK, "success", task.GetName()) } diff --git a/api/docs/docs.go b/api/docs/docs.go index 4cad2c5b..51738247 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -3798,28 +3798,53 @@ const docTemplate = `{ } } }, + "schema.EmailAttachment": { + "type": "object", + "required": [ + "data", + "name" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "integer" + } + }, + "name": { + "type": "string" + } + } + }, "schema.EmailRequest": { "type": "object", "required": [ "body", - "from", "subject", "to" ], "properties": { + "attachments": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.EmailAttachment" + } + }, "body": { "type": "string" }, + "embeds": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.EmailAttachment" + } + }, "from": { "type": "string" }, "subject": { "type": "string" }, - "task_name": { - "description": "Included if queued via Cloud Tasks", - "type": "string" - }, "to": { "type": "string" } diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index 971b89c4..61ed28f6 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -439,22 +439,38 @@ definitions: title: type: string type: object + schema.EmailAttachment: + properties: + data: + items: + type: integer + type: array + name: + type: string + required: + - data + - name + type: object schema.EmailRequest: properties: + attachments: + items: + $ref: '#/definitions/schema.EmailAttachment' + type: array body: type: string + embeds: + items: + $ref: '#/definitions/schema.EmailAttachment' + type: array from: type: string subject: type: string - task_name: - description: Included if queued via Cloud Tasks - type: string to: type: string required: - body - - from - subject - to type: object diff --git a/api/schema/objects.go b/api/schema/objects.go index 74c64470..49e0896c 100644 --- a/api/schema/objects.go +++ b/api/schema/objects.go @@ -334,13 +334,18 @@ type Degree struct { JointProgram bool `bson:"joint_program" json:"joint_program"` } -// Email Request Body +type EmailAttachment struct { + Name string `json:"name" binding:"required"` + Data []byte `json:"data" binding:"required"` +} + type EmailRequest struct { - From string `json:"from" binding:"required"` - To string `json:"to" binding:"required,email"` - Subject string `json:"subject" binding:"required"` - Body string `json:"body" binding:"required"` - TaskName string `json:"task_name,omitempty"` // Included if queued via Cloud Tasks + From string `json:"from,omitempty"` + To string `json:"to" binding:"required,email"` + Subject string `json:"subject" binding:"required"` + Body string `json:"body" binding:"required"` + Attachments []EmailAttachment `json:"attachments,omitempty"` + Embeds []EmailAttachment `json:"embeds,omitempty"` } // Type for all API responses From 9f089d17df154f30c701319de025e68ad300abae Mon Sep 17 00:00:00 2001 From: Justin Date: Fri, 6 Mar 2026 13:01:01 -0600 Subject: [PATCH 19/61] Add swagger internal tags --- api/controllers/email.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/controllers/email.go b/api/controllers/email.go index e866f170..0dcdc56f 100644 --- a/api/controllers/email.go +++ b/api/controllers/email.go @@ -60,6 +60,7 @@ func getQueueUrl(c *gin.Context) string { // @Id sendEmail // @Router /email/send [post] +// @Tags Internal // @Description "Send an email via SMTP. This route is restricted to only Nebula Labs internal Projects." // @Accept json // @Produce json @@ -111,6 +112,7 @@ func SendEmail(c *gin.Context) { // @Id QueueEmail // @Router /email/queue [post] +// @Tags Internal // @Description "Queue an email to be sent via SMTP. This route is restricted to only Nebula Labs internal Projects." // @Accept json // @Produce json From ad1506d5bcd1d751d523b4fa2c08630cba75eef2 Mon Sep 17 00:00:00 2001 From: Justin Date: Fri, 6 Mar 2026 13:03:17 -0600 Subject: [PATCH 20/61] Formatting and swagger update --- api/configs/setup_test.go | 4 ++-- api/controllers/astra.go | 9 +++++---- api/controllers/events.go | 7 +++---- api/docs/docs.go | 6 ++++++ api/docs/swagger.yaml | 4 ++++ 5 files changed, 20 insertions(+), 10 deletions(-) diff --git a/api/configs/setup_test.go b/api/configs/setup_test.go index 6629bb66..264edbd8 100644 --- a/api/configs/setup_test.go +++ b/api/configs/setup_test.go @@ -23,11 +23,11 @@ func TestGetOptionLimit(t *testing.T) { if err != nil { t.Fatalf("Expected no error, got %v", err) // Use Fatalf to stop if options is nil } - + if _, exists := query["offset"]; exists { t.Error("Expected 'offset' to be deleted from the query map") } - + // Ensure we compare the same types (int64) if options.Skip == nil || *options.Skip != int64(25) { t.Errorf("Expected Skip to be 25, got %v", options.Skip) diff --git a/api/controllers/astra.go b/api/controllers/astra.go index dc0ddbef..7ade6bec 100644 --- a/api/controllers/astra.go +++ b/api/controllers/astra.go @@ -4,8 +4,9 @@ import ( "context" "errors" "net/http" + "strings" // adding missing import "time" - "strings" // adding missing import + "github.com/gin-gonic/gin" "go.mongodb.org/mongo-driver/bson" @@ -78,10 +79,10 @@ func AstraEventsByBuilding(c *gin.Context) { } respondWithInternalError(c, err) return - } + } // case insensitive matching - for _, b:= range astra_events.Buildings { + for _, b := range astra_events.Buildings { if strings.EqualFold(strings.TrimSpace(b.Building), building) { astra_eventsByBuilding = b break @@ -163,7 +164,7 @@ func AstraEventsByBuildingAndRoom(c *gin.Context) { for i := 0; i < maxRooms; i++ { available = append(available, strings.TrimSpace(matchedBuilding.Rooms[i].Room)) } - + respond(c, http.StatusNotFound, "error", "Room not found. Available in this building: "+strings.Join(available, ", ")) return } diff --git a/api/controllers/events.go b/api/controllers/events.go index db8bef33..8bd1d9f6 100644 --- a/api/controllers/events.go +++ b/api/controllers/events.go @@ -4,8 +4,8 @@ import ( "context" "errors" "net/http" - "time" "strings" // adding missing import + "time" "github.com/UTDNebula/nebula-api/api/configs" @@ -71,7 +71,6 @@ func EventsByBuilding(c *gin.Context) { var events schema.MultiBuildingEvents[schema.SectionWithTime] var eventsByBuilding schema.SingleBuildingEvents[schema.SectionWithTime] - // find and parse matching date err := eventsCollection.FindOne(ctx, bson.M{"date": date}).Decode(&events) if err != nil { @@ -83,7 +82,7 @@ func EventsByBuilding(c *gin.Context) { return } } - + // case insensitive filter after data is retrieved for _, b := range events.Buildings { if strings.EqualFold(strings.TrimSpace(b.Building), building) { @@ -91,7 +90,7 @@ func EventsByBuilding(c *gin.Context) { break } } - + // if no building is found, return an err with suggestion if eventsByBuilding.Building == "" { maxBuildings := min(len(events.Buildings), 10) diff --git a/api/docs/docs.go b/api/docs/docs.go index 51738247..b401050c 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -900,6 +900,9 @@ const docTemplate = `{ "produces": [ "application/json" ], + "tags": [ + "Internal" + ], "operationId": "QueueEmail", "parameters": [ { @@ -950,6 +953,9 @@ const docTemplate = `{ "produces": [ "application/json" ], + "tags": [ + "Internal" + ], "operationId": "sendEmail", "parameters": [ { diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index 61ed28f6..1711bcb9 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -1511,6 +1511,8 @@ paths: description: A string describing the error schema: $ref: '#/definitions/schema.APIResponse-string' + tags: + - Internal /email/send: post: consumes: @@ -1545,6 +1547,8 @@ paths: description: A string describing the error schema: $ref: '#/definitions/schema.APIResponse-string' + tags: + - Internal /events/{date}: get: description: '"Returns all sections with meetings on the specified date"' From 71afc259c385b9b7694c373be2e2ccbcfb337a89 Mon Sep 17 00:00:00 2001 From: Justin Date: Fri, 6 Mar 2026 13:14:08 -0600 Subject: [PATCH 21/61] Update docs.go again --- api/docs/docs.go | 58 ++++++++++++++++++++++++------------------------ 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/api/docs/docs.go b/api/docs/docs.go index 60fb7257..02d491d4 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -3891,6 +3891,35 @@ const docTemplate = `{ } } }, + "schema.DiscountProgram": { + "type": "object", + "properties": { + "_id": { + "type": "string" + }, + "address": { + "type": "string" + }, + "business": { + "type": "string" + }, + "category": { + "type": "string" + }, + "discount": { + "type": "string" + }, + "email": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "website": { + "type": "string" + } + } + }, "schema.EmailAttachment": { "type": "object", "required": [ @@ -3943,35 +3972,6 @@ const docTemplate = `{ } } }, - "schema.DiscountProgram": { - "type": "object", - "properties": { - "_id": { - "type": "string" - }, - "address": { - "type": "string" - }, - "business": { - "type": "string" - }, - "category": { - "type": "string" - }, - "discount": { - "type": "string" - }, - "email": { - "type": "string" - }, - "phone": { - "type": "string" - }, - "website": { - "type": "string" - } - } - }, "schema.Event": { "type": "object", "properties": { From cce298ad5c96230c2c08583a8831eca458b55fe5 Mon Sep 17 00:00:00 2001 From: Justin Date: Fri, 6 Mar 2026 13:19:07 -0600 Subject: [PATCH 22/61] Remove TODO: --- api/routes/email.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/routes/email.go b/api/routes/email.go index 65c029cb..0b17af22 100644 --- a/api/routes/email.go +++ b/api/routes/email.go @@ -51,7 +51,7 @@ func initTasksClient() (*cloudtasks.Client, string, string) { func initEmailClient() (*mail.Client, string) { // Singleton to prevent multiple clients emailClientOnce.Do(func() { - smtpHost := os.Getenv("SMTP_HOST") // TODO: use lookupenv instead + smtpHost := os.Getenv("SMTP_HOST") smtpUser := os.Getenv("SMTP_USERNAME") smtpPass := os.Getenv("SMTP_PASSWORD") smtpFrom := os.Getenv("SMTP_FROM") From 055d31e1b76c486e9827a461779fbe89bd8bfa3b Mon Sep 17 00:00:00 2001 From: mikehquan19 Date: Sat, 7 Mar 2026 04:50:33 -0600 Subject: [PATCH 23/61] Update discounts endpoints --- api/controllers/discounts.go | 140 ++++++++++++++++++++++++++++------- 1 file changed, 114 insertions(+), 26 deletions(-) diff --git a/api/controllers/discounts.go b/api/controllers/discounts.go index ec67f3d2..ecda529c 100644 --- a/api/controllers/discounts.go +++ b/api/controllers/discounts.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "regexp" + "strconv" "strings" "time" @@ -53,26 +54,59 @@ func DiscountSearch(c *gin.Context) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - query, err := buildDiscountSearchQuery(c) - if err != nil { - respond(c, http.StatusBadRequest, "Invalid query parameters", err.Error()) - return - } + var cursor *mongo.Cursor + var err error - optionLimit, err := configs.GetOptionLimit(&query, c) - if err != nil { - respond(c, http.StatusBadRequest, "offset is not type integer", err.Error()) - return - } + _, hasQ := c.GetQuery("q") + _, hasBusiness := c.GetQuery("business") + _, hasAddress := c.GetQuery("address") + _, hasDiscount := c.GetQuery("discount") + _, hasCategory := c.GetQuery("category") + if hasQ { + if hasBusiness || hasAddress || hasDiscount || hasCategory { + // q may only be used alone + respond(c, http.StatusBadRequest, "Invalid query parameters", "Parameter q may not be used with other parameters") + return + } - cursor, err := discountCollection.Find(ctx, query, optionLimit) - if err != nil { - respondWithInternalError(c, err) - return + pipeline, err := buildFuzzySearchPipeline(c) + if err != nil { + respond(c, http.StatusBadRequest, "Invalid query parameters", err.Error()) + return + } + cursor, err = discountCollection.Aggregate(ctx, pipeline) + if err != nil { + respondWithInternalError(c, err) + return + } + + } else { + if !hasBusiness && !hasAddress && !hasDiscount && !hasCategory { + respond(c, http.StatusBadRequest, "Invalid query parameters", "Unknown query") + return + } + + query, err := buildDiscountSearchQuery(c) + if err != nil { + respond(c, http.StatusBadRequest, "Invalid query parameters", err.Error()) + return + } + + optionLimit, err := configs.GetOptionLimit(&query, c) + if err != nil { + respond(c, http.StatusBadRequest, "offset is not type integer", err.Error()) + return + } + cursor, err = discountCollection.Find(ctx, query, optionLimit) + if err != nil { + respondWithInternalError(c, err) + return + } } + defer cursor.Close(ctx) - discounts := make([]schema.DiscountProgram, 0) + var discounts []schema.DiscountProgram if err = cursor.All(ctx, &discounts); err != nil { respondWithInternalError(c, err) return @@ -82,24 +116,15 @@ func DiscountSearch(c *gin.Context) { } +// Build the query for field-based search func buildDiscountSearchQuery(c *gin.Context) (bson.M, error) { business, hasBusiness := c.GetQuery("business") address, hasAddress := c.GetQuery("address") discount, hasDiscount := c.GetQuery("discount") category, hasCategory := c.GetQuery("category") - q, hasQ := c.GetQuery("q") query := bson.M{} - if hasQ { - // q may only be used alone - if hasBusiness || hasAddress || hasDiscount || hasCategory { - return nil, fmt.Errorf("parameter q may not be used with other parameters") - } - query["$text"] = bson.D{{Key: "$search", Value: q}} - return query, nil - } - // We use regexp.QuoteMeta and option i to essentially do string.toLower().contains(key) on fields if hasBusiness { cleanedBusiness := strings.TrimSpace(regexp.QuoteMeta(business)) @@ -125,9 +150,72 @@ func buildDiscountSearchQuery(c *gin.Context) (bson.M, error) { } } if !found { - return nil, fmt.Errorf("unknown category %s. Valid categories are %s", category, strings.Join(discountCategories, ", ")) + return nil, fmt.Errorf("unknown category %s. Valid categories are %s", category, strings.Join(discountCategories, " | ")) } } return query, nil } + +// Build the pipeline to perform fuzzy search on q +func buildFuzzySearchPipeline(c *gin.Context) (mongo.Pipeline, error) { + q, _ := c.GetQuery("q") + if strings.TrimSpace(q) == "" { + return mongo.Pipeline{}, fmt.Errorf("empty q") + } + + var offset int64 + var err error + if c.Query("offset") == "" { + offset = 0 + } else { + offset, err = strconv.ParseInt(c.Query("offset"), 10, 64) + if err != nil { + return mongo.Pipeline{}, err + } + } + + var fuzzySearchArr bson.A + fields := [4]string{"category", "discount", "business", "address"} + maxEditsList := [4]int{2, 2, 2, 1} + boostScores := [4]int{5, 3, 2, 1} + for i, field := range fields { + fuzzySearchArr = append(fuzzySearchArr, bson.D{ + {Key: "text", Value: bson.D{ + {Key: "query", Value: q}, + {Key: "path", Value: field}, + {Key: "fuzzy", Value: bson.D{ + {Key: "maxEdits", Value: maxEditsList[i]}, + }}, + {Key: "score", Value: bson.D{ + {Key: "boost", Value: bson.D{ + {Key: "value", Value: boostScores[i]}, + }}, + }}, + }}, + }) + } + + return mongo.Pipeline{ + // Fuzzy searches + bson.D{ + {Key: "$search", Value: bson.D{ + {Key: "index", Value: "discount_searches"}, + {Key: "compound", Value: bson.D{ + {Key: "should", Value: fuzzySearchArr}, + }}, + }}, + }, + + // Sort and paginate + bson.D{ + {Key: "$sort", Value: bson.D{ + {Key: "score", Value: bson.D{ + {Key: "$meta", Value: "searchScore"}, + }}, + }}, + }, + bson.D{{Key: "$skip", Value: offset}}, + bson.D{{Key: "$limit", Value: configs.GetEnvLimit()}}, + }, nil +} From 3c2306ec857fd6e10791d7b57504ffef7707b757 Mon Sep 17 00:00:00 2001 From: mikehquan19 Date: Sat, 7 Mar 2026 05:17:17 -0600 Subject: [PATCH 24/61] Comments docs, I'm passing out --- api/controllers/discounts.go | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/api/controllers/discounts.go b/api/controllers/discounts.go index ecda529c..39d49dce 100644 --- a/api/controllers/discounts.go +++ b/api/controllers/discounts.go @@ -116,7 +116,8 @@ func DiscountSearch(c *gin.Context) { } -// Build the query for field-based search +// buildDiscountSearchQuery constructs the Mongo query for FIELD-BASED SEARCH. +// Users only search for 4 main fields of discount func buildDiscountSearchQuery(c *gin.Context) (bson.M, error) { business, hasBusiness := c.GetQuery("business") address, hasAddress := c.GetQuery("address") @@ -140,16 +141,16 @@ func buildDiscountSearchQuery(c *gin.Context) (bson.M, error) { } if hasCategory { - found := false + categoryFound := false for _, discountCategory := range discountCategories { - //case insensitive equal + // Case insensitive equal if strings.EqualFold(discountCategory, category) { query["category"] = discountCategory - found = true + categoryFound = true break } } - if !found { + if !categoryFound { return nil, fmt.Errorf("unknown category %s. Valid categories are %s", category, strings.Join(discountCategories, " | ")) } } @@ -157,13 +158,14 @@ func buildDiscountSearchQuery(c *gin.Context) (bson.M, error) { return query, nil } -// Build the pipeline to perform fuzzy search on q +// buildFuzzySearchPipeline constructs the pipeline to perform fuzzy search on keyword q. func buildFuzzySearchPipeline(c *gin.Context) (mongo.Pipeline, error) { q, _ := c.GetQuery("q") if strings.TrimSpace(q) == "" { return mongo.Pipeline{}, fmt.Errorf("empty q") } + // Literally copy from getOptionsLimit() var offset int64 var err error if c.Query("offset") == "" { @@ -207,7 +209,7 @@ func buildFuzzySearchPipeline(c *gin.Context) (mongo.Pipeline, error) { }}, }, - // Sort and paginate + // Sort based on relevancy score for deterministism and paginate bson.D{ {Key: "$sort", Value: bson.D{ {Key: "score", Value: bson.D{ From 1a9a941f8c17c6ef40c3ab52cf5edea6ec2d1090 Mon Sep 17 00:00:00 2001 From: mikehquan19 Date: Tue, 10 Mar 2026 11:13:28 -0500 Subject: [PATCH 25/61] Much cleaner query params parsing now --- api/controllers/discounts.go | 136 +++++++++++++++-------------------- api/schema/objects.go | 17 +++++ 2 files changed, 73 insertions(+), 80 deletions(-) diff --git a/api/controllers/discounts.go b/api/controllers/discounts.go index 39d49dce..1231626c 100644 --- a/api/controllers/discounts.go +++ b/api/controllers/discounts.go @@ -5,7 +5,6 @@ import ( "fmt" "net/http" "regexp" - "strconv" "strings" "time" @@ -14,6 +13,7 @@ import ( "github.com/gin-gonic/gin" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" ) var discountCollection *mongo.Collection = configs.GetCollection("discounts") @@ -39,14 +39,14 @@ var discountCategories = []string{ // @Id discountPrograms // @Router /discountPrograms [get] // @Tags Discounts -// @Description "Returns paginated list of discounts matching the query's string-typed key-value pairs. See offset for more details on pagination." +// @Description "Returns paginated list of discounts filtered using field-specific keyword searches or global fuzzy search. See offset for more details on pagination." // @Produce json // @Param offset query number false "The starting position of the current page of discounts (e.g. For starting at the 17th discount, offset=16)." -// @Param category query string false "The discount's category." +// @Param category query string false "The discount's category (exact match with suggestions)." // @Param business query string false "The discount's business contains this keyword (case-insensitive)." // @Param address query string false "The discount's address contains this keyword (case-insensitive)." // @Param discount query string false "The discount's discount contains this keyword (case-insensitive)." -// @Param q query string false "Full text search of all discount's fields." +// @Param q query string false "Fuzzy search, must be used alone." // @Success 200 {object} schema.APIResponse[[]schema.DiscountProgram] "A list of discounts" // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 400 {object} schema.APIResponse[string] "A string describing the error" @@ -57,56 +57,44 @@ func DiscountSearch(c *gin.Context) { var cursor *mongo.Cursor var err error - _, hasQ := c.GetQuery("q") - _, hasBusiness := c.GetQuery("business") - _, hasAddress := c.GetQuery("address") - _, hasDiscount := c.GetQuery("discount") - _, hasCategory := c.GetQuery("category") - if hasQ { - if hasBusiness || hasAddress || hasDiscount || hasCategory { - // q may only be used alone - respond(c, http.StatusBadRequest, "Invalid query parameters", "Parameter q may not be used with other parameters") - return - } + var params schema.DiscountQueryParams + if err = c.ShouldBindQuery(¶ms); err != nil { + respond(c, http.StatusBadRequest, "Invalid query parameters", err.Error()) + return + } + params.TrimSpace() - pipeline, err := buildFuzzySearchPipeline(c) - if err != nil { - respond(c, http.StatusBadRequest, "Invalid query parameters", err.Error()) + if params.Q != "" { + if params.Category != "" || params.Business != "" || + params.Address != "" || params.Discount != "" { + respond(c, http.StatusBadRequest, "Invalid query parameters", "q must be used alone") return } - cursor, err = discountCollection.Aggregate(ctx, pipeline) + + fuzzyPipeline := buildFuzzySearchPipeline(params.Q, params.Offset) + cursor, err = discountCollection.Aggregate(ctx, fuzzyPipeline) if err != nil { respondWithInternalError(c, err) return } - } else { - if !hasBusiness && !hasAddress && !hasDiscount && !hasCategory { - respond(c, http.StatusBadRequest, "Invalid query parameters", "Unknown query") - return - } - - query, err := buildDiscountSearchQuery(c) + // Either fields are specified or not + query, err := buildDiscountSearchQuery(params) if err != nil { respond(c, http.StatusBadRequest, "Invalid query parameters", err.Error()) return } - optionLimit, err := configs.GetOptionLimit(&query, c) - if err != nil { - respond(c, http.StatusBadRequest, "offset is not type integer", err.Error()) - return - } + optionLimit := options.Find().SetSkip(params.Offset).SetLimit(configs.GetEnvLimit()) cursor, err = discountCollection.Find(ctx, query, optionLimit) if err != nil { respondWithInternalError(c, err) return } } - defer cursor.Close(ctx) - var discounts []schema.DiscountProgram + discounts := make([]schema.DiscountProgram, 0) if err = cursor.All(ctx, &discounts); err != nil { respondWithInternalError(c, err) return @@ -118,40 +106,34 @@ func DiscountSearch(c *gin.Context) { // buildDiscountSearchQuery constructs the Mongo query for FIELD-BASED SEARCH. // Users only search for 4 main fields of discount -func buildDiscountSearchQuery(c *gin.Context) (bson.M, error) { - business, hasBusiness := c.GetQuery("business") - address, hasAddress := c.GetQuery("address") - discount, hasDiscount := c.GetQuery("discount") - category, hasCategory := c.GetQuery("category") - +func buildDiscountSearchQuery(q schema.DiscountQueryParams) (bson.M, error) { query := bson.M{} // We use regexp.QuoteMeta and option i to essentially do string.toLower().contains(key) on fields - if hasBusiness { - cleanedBusiness := strings.TrimSpace(regexp.QuoteMeta(business)) - query["business"] = bson.D{{Key: "$regex", Value: cleanedBusiness}, {Key: "$options", Value: "i"}} + if q.Business != "" { + business := regexp.QuoteMeta(q.Business) + query["business"] = bson.D{{Key: "$regex", Value: business}, {Key: "$options", Value: "i"}} } - if hasAddress { - cleanedAddress := strings.TrimSpace(regexp.QuoteMeta(address)) - query["address"] = bson.D{{Key: "$regex", Value: cleanedAddress}, {Key: "$options", Value: "i"}} + if q.Address != "" { + address := regexp.QuoteMeta(q.Address) + query["address"] = bson.D{{Key: "$regex", Value: address}, {Key: "$options", Value: "i"}} } - if hasDiscount { - cleanedDiscount := strings.TrimSpace(regexp.QuoteMeta(discount)) - query["discount"] = bson.D{{Key: "$regex", Value: cleanedDiscount}, {Key: "$options", Value: "i"}} + if q.Discount != "" { + discount := regexp.QuoteMeta(q.Discount) + query["discount"] = bson.D{{Key: "$regex", Value: discount}, {Key: "$options", Value: "i"}} } - if hasCategory { + if q.Category != "" { categoryFound := false for _, discountCategory := range discountCategories { - // Case insensitive equal - if strings.EqualFold(discountCategory, category) { + if discountCategory == q.Category { query["category"] = discountCategory categoryFound = true break } } if !categoryFound { - return nil, fmt.Errorf("unknown category %s. Valid categories are %s", category, strings.Join(discountCategories, " | ")) + return nil, fmt.Errorf("unknown category, valid categories are %s", strings.Join(discountCategories, " | ")) } } @@ -159,39 +141,33 @@ func buildDiscountSearchQuery(c *gin.Context) (bson.M, error) { } // buildFuzzySearchPipeline constructs the pipeline to perform fuzzy search on keyword q. -func buildFuzzySearchPipeline(c *gin.Context) (mongo.Pipeline, error) { - q, _ := c.GetQuery("q") - if strings.TrimSpace(q) == "" { - return mongo.Pipeline{}, fmt.Errorf("empty q") +func buildFuzzySearchPipeline(q string, offset int64) mongo.Pipeline { + type FuzzyConfig struct { + Field string + maxEdits int + boostScore int } - - // Literally copy from getOptionsLimit() - var offset int64 - var err error - if c.Query("offset") == "" { - offset = 0 - } else { - offset, err = strconv.ParseInt(c.Query("offset"), 10, 64) - if err != nil { - return mongo.Pipeline{}, err - } + // Will need to tune the configuration to get better results + fuzzyConfigs := []FuzzyConfig{ + {"category", 2, 5}, + {"discount", 2, 3}, + {"business", 2, 2}, + {"address", 1, 1}, } - var fuzzySearchArr bson.A - fields := [4]string{"category", "discount", "business", "address"} - maxEditsList := [4]int{2, 2, 2, 1} - boostScores := [4]int{5, 3, 2, 1} - for i, field := range fields { - fuzzySearchArr = append(fuzzySearchArr, bson.D{ + var fuzzySearches bson.A + for _, fuzzyConfig := range fuzzyConfigs { + fuzzySearches = append(fuzzySearches, bson.D{ {Key: "text", Value: bson.D{ {Key: "query", Value: q}, - {Key: "path", Value: field}, + {Key: "path", Value: fuzzyConfig.Field}, {Key: "fuzzy", Value: bson.D{ - {Key: "maxEdits", Value: maxEditsList[i]}, + {Key: "maxEdits", Value: fuzzyConfig.maxEdits}, + {Key: "prefixLength", Value: 2}, // Should match first 2 characters }}, {Key: "score", Value: bson.D{ {Key: "boost", Value: bson.D{ - {Key: "value", Value: boostScores[i]}, + {Key: "value", Value: fuzzyConfig.boostScore}, }}, }}, }}, @@ -199,17 +175,17 @@ func buildFuzzySearchPipeline(c *gin.Context) (mongo.Pipeline, error) { } return mongo.Pipeline{ - // Fuzzy searches bson.D{ {Key: "$search", Value: bson.D{ {Key: "index", Value: "discount_searches"}, {Key: "compound", Value: bson.D{ - {Key: "should", Value: fuzzySearchArr}, + {Key: "should", Value: fuzzySearches}, + {Key: "minimumShouldMatch", Value: 1}, // Prevent extremely unrelated docs }}, }}, }, - // Sort based on relevancy score for deterministism and paginate + // Sort based on relevancy score for determinism and paginate bson.D{ {Key: "$sort", Value: bson.D{ {Key: "score", Value: bson.D{ @@ -219,5 +195,5 @@ func buildFuzzySearchPipeline(c *gin.Context) (mongo.Pipeline, error) { }, bson.D{{Key: "$skip", Value: offset}}, bson.D{{Key: "$limit", Value: configs.GetEnvLimit()}}, - }, nil + } } diff --git a/api/schema/objects.go b/api/schema/objects.go index 27fa58f5..7c52d124 100644 --- a/api/schema/objects.go +++ b/api/schema/objects.go @@ -136,6 +136,23 @@ type DiscountProgram struct { Discount string `bson:"discount" json:"discount"` } +type DiscountQueryParams struct { + Offset int64 `form:"offset" binding:"gte=0"` + Category string `form:"category"` + Business string `form:"business"` + Address string `form:"address"` + Discount string `form:"discount"` + Q string `form:"q"` +} + +func (q *DiscountQueryParams) TrimSpace() { + q.Category = strings.TrimSpace(q.Category) + q.Business = strings.TrimSpace(q.Business) + q.Address = strings.TrimSpace(q.Address) + q.Discount = strings.TrimSpace(q.Discount) + q.Q = strings.TrimSpace(q.Q) +} + type Event struct { Id primitive.ObjectID `bson:"_id" json:"_id"` Summary string `bson:"summary" json:"summary"` From 38d1b3c47a70f53a8c7ceec5d40f03f13ca0c9b9 Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 11 Mar 2026 11:43:11 -0500 Subject: [PATCH 26/61] tidy code --- api/docs/docs.go | 6 +++--- api/docs/swagger.yaml | 8 ++++---- api/go.sum | 8 ++++---- api/schema/objects.go | 2 -- 4 files changed, 11 insertions(+), 13 deletions(-) diff --git a/api/docs/docs.go b/api/docs/docs.go index bbbdecd7..f18d3e46 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -975,7 +975,7 @@ const docTemplate = `{ }, "/discountPrograms": { "get": { - "description": "\"Returns paginated list of discounts matching the query's string-typed key-value pairs. See offset for more details on pagination.\"", + "description": "\"Returns paginated list of discounts filtered using field-specific keyword searches or global fuzzy search. See offset for more details on pagination.\"", "produces": [ "application/json" ], @@ -992,7 +992,7 @@ const docTemplate = `{ }, { "type": "string", - "description": "The discount's category.", + "description": "The discount's category (exact match with suggestions).", "name": "category", "in": "query" }, @@ -1016,7 +1016,7 @@ const docTemplate = `{ }, { "type": "string", - "description": "Full text search of all discount's fields.", + "description": "Fuzzy search, must be used alone.", "name": "q", "in": "query" } diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index 6d57a1cf..a206dded 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -1619,8 +1619,8 @@ paths: - Courses /discountPrograms: get: - description: '"Returns paginated list of discounts matching the query''s string-typed - key-value pairs. See offset for more details on pagination."' + description: '"Returns paginated list of discounts filtered using field-specific + keyword searches or global fuzzy search. See offset for more details on pagination."' operationId: discountPrograms parameters: - description: The starting position of the current page of discounts (e.g. @@ -1628,7 +1628,7 @@ paths: in: query name: offset type: number - - description: The discount's category. + - description: The discount's category (exact match with suggestions). in: query name: category type: string @@ -1644,7 +1644,7 @@ paths: in: query name: discount type: string - - description: Full text search of all discount's fields. + - description: Fuzzy search, must be used alone. in: query name: q type: string diff --git a/api/go.sum b/api/go.sum index 91482dc6..fcec973c 100644 --- a/api/go.sum +++ b/api/go.sum @@ -131,10 +131,10 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.5 h1:VgzTY2jogw3xt39CusEnFJWm7rlsq5yL5q9XdLOuP5g= -github.com/googleapis/enterprise-certificate-proxy v0.3.5/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= -github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= -github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= +github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= +github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= +github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= diff --git a/api/schema/objects.go b/api/schema/objects.go index 6a67f2b7..5eae041b 100644 --- a/api/schema/objects.go +++ b/api/schema/objects.go @@ -382,8 +382,6 @@ type EmailRequest struct { Embeds []EmailAttachment `json:"embeds,omitempty"` } - - // Type for all API responses type APIResponse[T any] struct { Status int `json:"status"` From 080f37f951a7503b1cd60e47af853080ea851a4d Mon Sep 17 00:00:00 2001 From: mikehquan19 Date: Thu, 12 Mar 2026 00:47:02 -0500 Subject: [PATCH 27/61] Fetch the list of discount categories dynamically --- api/controllers/discounts.go | 123 +++++++++++++++++++---------------- api/schema/objects.go | 24 +++++-- 2 files changed, 85 insertions(+), 62 deletions(-) diff --git a/api/controllers/discounts.go b/api/controllers/discounts.go index 1231626c..827b28d9 100644 --- a/api/controllers/discounts.go +++ b/api/controllers/discounts.go @@ -3,9 +3,11 @@ package controllers import ( "context" "fmt" + "log" "net/http" "regexp" "strings" + "sync" "time" "github.com/UTDNebula/nebula-api/api/configs" @@ -18,23 +20,8 @@ import ( var discountCollection *mongo.Collection = configs.GetCollection("discounts") -// discountCategories -// potentially we may want to add an init function to create this list dynamically -var discountCategories = []string{ - "Accommodations", - "Auto Services", - "Child Care", - "Clothes, Flowers and Gifts", - "Dining", - "Entertainment", - "Health and Beauty", - "Home and Garden", - "Housing", - "Miscellaneous", - "Pet Care", - "Professional Services", - "Technology", -} +var discountCategories []string +var discountCategoriesOnce sync.Once // @Id discountPrograms // @Router /discountPrograms [get] @@ -51,6 +38,7 @@ var discountCategories = []string{ // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 400 {object} schema.APIResponse[string] "A string describing the error" func DiscountSearch(c *gin.Context) { + fetchDiscountCategories() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -65,28 +53,27 @@ func DiscountSearch(c *gin.Context) { params.TrimSpace() if params.Q != "" { - if params.Category != "" || params.Business != "" || - params.Address != "" || params.Discount != "" { + if params.HasFields() { respond(c, http.StatusBadRequest, "Invalid query parameters", "q must be used alone") return } - fuzzyPipeline := buildFuzzySearchPipeline(params.Q, params.Offset) - cursor, err = discountCollection.Aggregate(ctx, fuzzyPipeline) + pipeline := buildFuzzySearchPipeline(params.Q, params.Offset) + cursor, err = discountCollection.Aggregate(ctx, pipeline) if err != nil { respondWithInternalError(c, err) return } } else { - // Either fields are specified or not - query, err := buildDiscountSearchQuery(params) + // If no fields are specified, it just returns paginated collection + discountQuery, err := buildDiscountSearchQuery(params) if err != nil { respond(c, http.StatusBadRequest, "Invalid query parameters", err.Error()) return } optionLimit := options.Find().SetSkip(params.Offset).SetLimit(configs.GetEnvLimit()) - cursor, err = discountCollection.Find(ctx, query, optionLimit) + cursor, err = discountCollection.Find(ctx, discountQuery, optionLimit) if err != nil { respondWithInternalError(c, err) return @@ -104,29 +91,55 @@ func DiscountSearch(c *gin.Context) { } +// initDiscountCategories aggregates the list of discount categories from DB once +func fetchDiscountCategories() { + discountCategoriesOnce.Do(func() { + ctx, cancel := context.WithTimeout(context.Background(), 7*time.Second) + defer cancel() + + results, err := discountCollection.Distinct(ctx, "category", bson.M{}) + if err != nil { + panic(err) + } + for _, result := range results { + category, ok := result.(string) + if !ok { + continue // Skip invalid category + } + discountCategories = append(discountCategories, category) + } + log.Printf("Available discount categories: %s.\n", discountCategories) + }) +} + // buildDiscountSearchQuery constructs the Mongo query for FIELD-BASED SEARCH. // Users only search for 4 main fields of discount -func buildDiscountSearchQuery(q schema.DiscountQueryParams) (bson.M, error) { +func buildDiscountSearchQuery(p schema.DiscountQueryParams) (bson.M, error) { query := bson.M{} - // We use regexp.QuoteMeta and option i to essentially do string.toLower().contains(key) on fields - if q.Business != "" { - business := regexp.QuoteMeta(q.Business) - query["business"] = bson.D{{Key: "$regex", Value: business}, {Key: "$options", Value: "i"}} + // Use regexp.QuoteMeta and option i for insensitive-matching + if p.Business != "" { + query["business"] = bson.D{ + {Key: "$regex", Value: regexp.QuoteMeta(p.Business)}, + {Key: "$options", Value: "i"}, + } } - if q.Address != "" { - address := regexp.QuoteMeta(q.Address) - query["address"] = bson.D{{Key: "$regex", Value: address}, {Key: "$options", Value: "i"}} + if p.Address != "" { + query["address"] = bson.D{ + {Key: "$regex", Value: regexp.QuoteMeta(p.Address)}, + {Key: "$options", Value: "i"}, + } } - if q.Discount != "" { - discount := regexp.QuoteMeta(q.Discount) - query["discount"] = bson.D{{Key: "$regex", Value: discount}, {Key: "$options", Value: "i"}} + if p.Discount != "" { + query["discount"] = bson.D{ + {Key: "$regex", Value: regexp.QuoteMeta(p.Discount)}, + {Key: "$options", Value: "i"}, + } } - - if q.Category != "" { + if p.Category != "" { categoryFound := false for _, discountCategory := range discountCategories { - if discountCategory == q.Category { + if discountCategory == p.Category { query["category"] = discountCategory categoryFound = true break @@ -142,32 +155,26 @@ func buildDiscountSearchQuery(q schema.DiscountQueryParams) (bson.M, error) { // buildFuzzySearchPipeline constructs the pipeline to perform fuzzy search on keyword q. func buildFuzzySearchPipeline(q string, offset int64) mongo.Pipeline { - type FuzzyConfig struct { - Field string - maxEdits int - boostScore int - } - // Will need to tune the configuration to get better results - fuzzyConfigs := []FuzzyConfig{ - {"category", 2, 5}, - {"discount", 2, 3}, - {"business", 2, 2}, - {"address", 1, 1}, - } - var fuzzySearches bson.A + // Tune the configuration to get better results + fuzzyConfigs := []schema.FuzzySearchConfig{ + {Field: "category", MaxEdits: 2, BoostScore: 5}, + {Field: "discount", MaxEdits: 2, BoostScore: 3}, + {Field: "business", MaxEdits: 2, BoostScore: 2}, + {Field: "address", MaxEdits: 1, BoostScore: 1}, + } for _, fuzzyConfig := range fuzzyConfigs { fuzzySearches = append(fuzzySearches, bson.D{ {Key: "text", Value: bson.D{ {Key: "query", Value: q}, {Key: "path", Value: fuzzyConfig.Field}, {Key: "fuzzy", Value: bson.D{ - {Key: "maxEdits", Value: fuzzyConfig.maxEdits}, - {Key: "prefixLength", Value: 2}, // Should match first 2 characters + {Key: "maxEdits", Value: fuzzyConfig.MaxEdits}, + {Key: "prefixLength", Value: 2}, // Should match first 2 chars }}, {Key: "score", Value: bson.D{ {Key: "boost", Value: bson.D{ - {Key: "value", Value: fuzzyConfig.boostScore}, + {Key: "value", Value: fuzzyConfig.BoostScore}, }}, }}, }}, @@ -180,12 +187,14 @@ func buildFuzzySearchPipeline(q string, offset int64) mongo.Pipeline { {Key: "index", Value: "discount_searches"}, {Key: "compound", Value: bson.D{ {Key: "should", Value: fuzzySearches}, - {Key: "minimumShouldMatch", Value: 1}, // Prevent extremely unrelated docs + + // Match at least 1 field to prevent super unrelated docs + {Key: "minimumShouldMatch", Value: 1}, }}, }}, }, - // Sort based on relevancy score for determinism and paginate + // Sort based on relevancy for determinism and paginate bson.D{ {Key: "$sort", Value: bson.D{ {Key: "score", Value: bson.D{ @@ -193,6 +202,8 @@ func buildFuzzySearchPipeline(q string, offset int64) mongo.Pipeline { }}, }}, }, + + // Paginate the results bson.D{{Key: "$skip", Value: offset}}, bson.D{{Key: "$limit", Value: configs.GetEnvLimit()}}, } diff --git a/api/schema/objects.go b/api/schema/objects.go index 7c52d124..7999a7f5 100644 --- a/api/schema/objects.go +++ b/api/schema/objects.go @@ -145,12 +145,24 @@ type DiscountQueryParams struct { Q string `form:"q"` } -func (q *DiscountQueryParams) TrimSpace() { - q.Category = strings.TrimSpace(q.Category) - q.Business = strings.TrimSpace(q.Business) - q.Address = strings.TrimSpace(q.Address) - q.Discount = strings.TrimSpace(q.Discount) - q.Q = strings.TrimSpace(q.Q) +// The configuration for fuzzy searches +type FuzzySearchConfig struct { + Field string + MaxEdits int + BoostScore int +} + +// TrimSpace sanitizes all of fields of the discount query params +func (params *DiscountQueryParams) TrimSpace() { + params.Category = strings.TrimSpace(params.Category) + params.Business = strings.TrimSpace(params.Business) + params.Address = strings.TrimSpace(params.Address) + params.Discount = strings.TrimSpace(params.Discount) + params.Q = strings.TrimSpace(params.Q) +} + +func (params *DiscountQueryParams) HasFields() bool { + return params.Category != "" || params.Business != "" || params.Address != "" || params.Discount != "" } type Event struct { From 3c4a6fbe3291a62bd5677fc2e434ecb09d8ebc38 Mon Sep 17 00:00:00 2001 From: mikehquan19 Date: Thu, 12 Mar 2026 01:01:49 -0500 Subject: [PATCH 28/61] Get rid of offset since no need for pagination on discounts --- api/controllers/discounts.go | 51 ++++++++++++++++-------------------- api/schema/objects.go | 1 - 2 files changed, 23 insertions(+), 29 deletions(-) diff --git a/api/controllers/discounts.go b/api/controllers/discounts.go index 827b28d9..7aa7ee33 100644 --- a/api/controllers/discounts.go +++ b/api/controllers/discounts.go @@ -6,7 +6,6 @@ import ( "log" "net/http" "regexp" - "strings" "sync" "time" @@ -15,7 +14,6 @@ import ( "github.com/gin-gonic/gin" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" - "go.mongodb.org/mongo-driver/mongo/options" ) var discountCollection *mongo.Collection = configs.GetCollection("discounts") @@ -26,9 +24,8 @@ var discountCategoriesOnce sync.Once // @Id discountPrograms // @Router /discountPrograms [get] // @Tags Discounts -// @Description "Returns paginated list of discounts filtered using field-specific keyword searches or global fuzzy search. See offset for more details on pagination." +// @Description "Returns list of discounts filtered using field-specific keyword searches or global fuzzy search." // @Produce json -// @Param offset query number false "The starting position of the current page of discounts (e.g. For starting at the 17th discount, offset=16)." // @Param category query string false "The discount's category (exact match with suggestions)." // @Param business query string false "The discount's business contains this keyword (case-insensitive)." // @Param address query string false "The discount's address contains this keyword (case-insensitive)." @@ -58,7 +55,7 @@ func DiscountSearch(c *gin.Context) { return } - pipeline := buildFuzzySearchPipeline(params.Q, params.Offset) + pipeline := buildFuzzySearchPipeline(params.Q) cursor, err = discountCollection.Aggregate(ctx, pipeline) if err != nil { respondWithInternalError(c, err) @@ -72,8 +69,7 @@ func DiscountSearch(c *gin.Context) { return } - optionLimit := options.Find().SetSkip(params.Offset).SetLimit(configs.GetEnvLimit()) - cursor, err = discountCollection.Find(ctx, discountQuery, optionLimit) + cursor, err = discountCollection.Find(ctx, discountQuery) if err != nil { respondWithInternalError(c, err) return @@ -91,7 +87,7 @@ func DiscountSearch(c *gin.Context) { } -// initDiscountCategories aggregates the list of discount categories from DB once +// fetchDiscountCategories aggregates the list of discount categories func fetchDiscountCategories() { discountCategoriesOnce.Do(func() { ctx, cancel := context.WithTimeout(context.Background(), 7*time.Second) @@ -108,7 +104,7 @@ func fetchDiscountCategories() { } discountCategories = append(discountCategories, category) } - log.Printf("Available discount categories: %s.\n", discountCategories) + log.Printf("Available categories: %s.\n", discountCategories) }) } @@ -124,18 +120,21 @@ func buildDiscountSearchQuery(p schema.DiscountQueryParams) (bson.M, error) { {Key: "$options", Value: "i"}, } } + if p.Address != "" { query["address"] = bson.D{ {Key: "$regex", Value: regexp.QuoteMeta(p.Address)}, {Key: "$options", Value: "i"}, } } + if p.Discount != "" { query["discount"] = bson.D{ {Key: "$regex", Value: regexp.QuoteMeta(p.Discount)}, {Key: "$options", Value: "i"}, } } + if p.Category != "" { categoryFound := false for _, discountCategory := range discountCategories { @@ -146,7 +145,7 @@ func buildDiscountSearchQuery(p schema.DiscountQueryParams) (bson.M, error) { } } if !categoryFound { - return nil, fmt.Errorf("unknown category, valid categories are %s", strings.Join(discountCategories, " | ")) + return nil, fmt.Errorf("unknown category, valid categories are %s.\n", discountCategories) } } @@ -154,28 +153,29 @@ func buildDiscountSearchQuery(p schema.DiscountQueryParams) (bson.M, error) { } // buildFuzzySearchPipeline constructs the pipeline to perform fuzzy search on keyword q. -func buildFuzzySearchPipeline(q string, offset int64) mongo.Pipeline { +func buildFuzzySearchPipeline(q string) mongo.Pipeline { var fuzzySearches bson.A - // Tune the configuration to get better results + // TODO: Tune the configuration to get better results fuzzyConfigs := []schema.FuzzySearchConfig{ {Field: "category", MaxEdits: 2, BoostScore: 5}, {Field: "discount", MaxEdits: 2, BoostScore: 3}, {Field: "business", MaxEdits: 2, BoostScore: 2}, {Field: "address", MaxEdits: 1, BoostScore: 1}, } - for _, fuzzyConfig := range fuzzyConfigs { + + for _, config := range fuzzyConfigs { fuzzySearches = append(fuzzySearches, bson.D{ {Key: "text", Value: bson.D{ {Key: "query", Value: q}, - {Key: "path", Value: fuzzyConfig.Field}, + {Key: "path", Value: config.Field}, {Key: "fuzzy", Value: bson.D{ - {Key: "maxEdits", Value: fuzzyConfig.MaxEdits}, - {Key: "prefixLength", Value: 2}, // Should match first 2 chars + {Key: "maxEdits", Value: config.MaxEdits}, + + // Should match first 2 characters + {Key: "prefixLength", Value: 2}, }}, {Key: "score", Value: bson.D{ - {Key: "boost", Value: bson.D{ - {Key: "value", Value: fuzzyConfig.BoostScore}, - }}, + {Key: "boost", Value: bson.D{{Key: "value", Value: config.BoostScore}}}, }}, }}, }) @@ -184,27 +184,22 @@ func buildFuzzySearchPipeline(q string, offset int64) mongo.Pipeline { return mongo.Pipeline{ bson.D{ {Key: "$search", Value: bson.D{ + // Name of the index search of this collection {Key: "index", Value: "discount_searches"}, {Key: "compound", Value: bson.D{ {Key: "should", Value: fuzzySearches}, - // Match at least 1 field to prevent super unrelated docs + // Should match at least 1 field to prevent super unrelated docs {Key: "minimumShouldMatch", Value: 1}, }}, }}, }, - // Sort based on relevancy for determinism and paginate + // Sort the results based on relevancy for determinism bson.D{ {Key: "$sort", Value: bson.D{ - {Key: "score", Value: bson.D{ - {Key: "$meta", Value: "searchScore"}, - }}, + {Key: "score", Value: bson.D{{Key: "$meta", Value: "searchScore"}}}, }}, }, - - // Paginate the results - bson.D{{Key: "$skip", Value: offset}}, - bson.D{{Key: "$limit", Value: configs.GetEnvLimit()}}, } } diff --git a/api/schema/objects.go b/api/schema/objects.go index 7999a7f5..a74d1380 100644 --- a/api/schema/objects.go +++ b/api/schema/objects.go @@ -137,7 +137,6 @@ type DiscountProgram struct { } type DiscountQueryParams struct { - Offset int64 `form:"offset" binding:"gte=0"` Category string `form:"category"` Business string `form:"business"` Address string `form:"address"` From 79283a649bbf0bd4a9ac5ea793a17db52c5fd439 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 01:14:18 +0000 Subject: [PATCH 29/61] Bump google.golang.org/grpc from 1.71.0 to 1.79.3 in /api Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.71.0 to 1.79.3. - [Release notes](https://github.com/grpc/grpc-go/releases) - [Commits](https://github.com/grpc/grpc-go/compare/v1.71.0...v1.79.3) --- updated-dependencies: - dependency-name: google.golang.org/grpc dependency-version: 1.79.3 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- api/go.mod | 38 ++++++++++++----------- api/go.sum | 88 +++++++++++++++++++++++++++++------------------------- 2 files changed, 68 insertions(+), 58 deletions(-) diff --git a/api/go.mod b/api/go.mod index 56babb68..8c5e3b83 100644 --- a/api/go.mod +++ b/api/go.mod @@ -18,24 +18,25 @@ require ( ) require ( - cel.dev/expr v0.19.2 // indirect + cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.118.3 // indirect cloud.google.com/go/auth v0.15.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect - cloud.google.com/go/compute/metadata v0.6.0 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.4.1 // indirect cloud.google.com/go/monitoring v1.24.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 // indirect - github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect - github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect + github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.22.0 // indirect @@ -52,8 +53,9 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.34.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect go.opentelemetry.io/otel v1.40.0 // indirect @@ -61,13 +63,13 @@ require ( go.opentelemetry.io/otel/sdk v1.40.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect - golang.org/x/oauth2 v0.28.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/time v0.10.0 // indirect - golang.org/x/tools v0.38.0 // indirect + golang.org/x/tools v0.39.0 // indirect google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect - google.golang.org/grpc v1.71.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.79.3 // indirect ) require ( @@ -108,12 +110,12 @@ require ( github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect golang.org/x/arch v0.21.0 // indirect - golang.org/x/crypto v0.45.0 // indirect - golang.org/x/mod v0.29.0 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/sync v0.18.0 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/mod v0.30.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.31.0 // indirect - google.golang.org/protobuf v1.36.8 // indirect + golang.org/x/text v0.32.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/api/go.sum b/api/go.sum index 35aad764..280dd212 100644 --- a/api/go.sum +++ b/api/go.sum @@ -1,13 +1,13 @@ -cel.dev/expr v0.19.2 h1:V354PbqIXr9IQdwy4SYA4xa0HXaWq1BUPAGzugBY5V4= -cel.dev/expr v0.19.2/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.118.3 h1:jsypSnrE/w4mJysioGdMBg4MiW/hHx/sArFpaBWHdME= cloud.google.com/go v0.118.3/go.mod h1:Lhs3YLnBlwJ4KA6nuObNMZ/fCbOQBPuWKPoE0Wa/9Vc= cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps= cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8= cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M= cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc= -cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= -cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/iam v1.4.1 h1:cFC25Nv+u5BkTR/BT1tXdoF2daiVbZ1RLx2eqfQ9RMM= cloud.google.com/go/iam v1.4.1/go.mod h1:2vUEJpUG3Q9p2UdsyksaKpDzlwOrnMzS30isdReIcLM= cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc= @@ -20,8 +20,8 @@ cloud.google.com/go/storage v1.51.0 h1:ZVZ11zCiD7b3k+cH5lQs/qcNaoSz3U9I0jgwVzqDl cloud.google.com/go/storage v1.51.0/go.mod h1:YEJfu/Ki3i5oHC/7jyTgsGZwdQ8P9hqMqvpi5kRKGgc= cloud.google.com/go/trace v1.11.3 h1:c+I4YFjxRQjvAhRmSsmjpASUKq88chOX854ied0K/pE= cloud.google.com/go/trace v1.11.3/go.mod h1:pt7zCYiDSQjC9Y2oqCsh9jF4GStB/hmjrYLsxRR27q8= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 h1:3c8yed4lgqTt+oTQ+JNMDo+F4xprBf+O/il4ZC0nRLw= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0/go.mod h1:obipzmGjfSjam60XLwGfqUkJsfiheAl+TUjG+4yzyPM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0 h1:OqVGm6Ei3x5+yZmSJG1Mh2NwHvpVmZ08CB5qJhT9Nuk= @@ -40,19 +40,20 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 h1:Om6kYQYDUk5wWbT0t0q6pvyM49i9XZAv9dDrkDA7gjk= -github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= -github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= -github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= -github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= -github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= -github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= @@ -69,6 +70,8 @@ github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -174,10 +177,13 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -211,8 +217,8 @@ go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFX go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/detectors/gcp v1.34.0 h1:JRxssobiPg23otYU5SbWtQC//snGVIM3Tx6QRzlQBao= -go.opentelemetry.io/contrib/detectors/gcp v1.34.0/go.mod h1:cV4BMFcscUR/ckqLkbfQmF0PRsq8w/lMGzdbCSveBHo= +go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE= +go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= @@ -235,23 +241,23 @@ golang.org/x/arch v0.21.0 h1:iTC9o7+wP6cPWpDWkivCvQFGAHDQ59SrSxsLPcnkArw= golang.org/x/arch v0.21.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= -golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -269,28 +275,30 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.224.0 h1:Ir4UPtDsNiwIOHdExr3fAj4xZ42QjK7uQte3lORLJwU= google.golang.org/api v0.224.0/go.mod h1:3V39my2xAGkodXy0vEqcEtkqgw2GtrFL5WuBZlCTCOQ= google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE= google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE= -google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= -google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb h1:TLPQVbx1GJ8VKZxz52VAxl1EBgKXXbTiU9Fc5fZeLn4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= -google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= -google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= -google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= -google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= From 722dd2c2b47c4203772accbc10da20ca4ca54858 Mon Sep 17 00:00:00 2001 From: mikehquan19 Date: Thu, 19 Mar 2026 13:59:03 -0500 Subject: [PATCH 30/61] Bump go version and dependency beyond current version of dependabot --- api/go.mod | 56 ++++++++++++++++++++++++++--------------------------- api/go.sum | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 29 deletions(-) diff --git a/api/go.mod b/api/go.mod index 8c5e3b83..dfc0a224 100644 --- a/api/go.mod +++ b/api/go.mod @@ -1,11 +1,9 @@ module github.com/UTDNebula/nebula-api/api -go 1.24.0 - -toolchain go1.24.2 +go 1.25.0 require ( - cloud.google.com/go/storage v1.51.0 + cloud.google.com/go/storage v1.61.3 github.com/getsentry/sentry-go v0.33.0 github.com/getsentry/sentry-go/gin v0.33.0 github.com/gin-gonic/gin v1.10.1 @@ -14,20 +12,20 @@ require ( github.com/swaggo/gin-swagger v1.6.1 github.com/swaggo/swag v1.16.6 go.mongodb.org/mongo-driver v1.17.4 - google.golang.org/api v0.224.0 + google.golang.org/api v0.271.0 ) require ( cel.dev/expr v0.25.1 // indirect - cloud.google.com/go v0.118.3 // indirect - cloud.google.com/go/auth v0.15.0 // indirect - cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect + cloud.google.com/go v0.123.0 // indirect + cloud.google.com/go/auth v0.18.2 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect - cloud.google.com/go/iam v1.4.1 // indirect - cloud.google.com/go/monitoring v1.24.0 // indirect + cloud.google.com/go/iam v1.5.3 // indirect + cloud.google.com/go/monitoring v1.24.3 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -45,8 +43,8 @@ require ( github.com/go-openapi/swag v0.24.1 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.5 // indirect - github.com/googleapis/gax-go/v2 v2.14.1 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect + github.com/googleapis/gax-go/v2 v2.17.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect @@ -56,19 +54,19 @@ require ( github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel/metric v1.40.0 // indirect go.opentelemetry.io/otel/sdk v1.40.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/time v0.10.0 // indirect - golang.org/x/tools v0.39.0 // indirect - google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.41.0 // indirect + google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/grpc v1.79.3 // indirect ) @@ -110,12 +108,12 @@ require ( github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect golang.org/x/arch v0.21.0 // indirect - golang.org/x/crypto v0.46.0 // indirect - golang.org/x/mod v0.30.0 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.32.0 // indirect - google.golang.org/protobuf v1.36.10 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/api/go.sum b/api/go.sum index 280dd212..753d938c 100644 --- a/api/go.sum +++ b/api/go.sum @@ -2,32 +2,52 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.118.3 h1:jsypSnrE/w4mJysioGdMBg4MiW/hHx/sArFpaBWHdME= cloud.google.com/go v0.118.3/go.mod h1:Lhs3YLnBlwJ4KA6nuObNMZ/fCbOQBPuWKPoE0Wa/9Vc= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps= cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8= +cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= +cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M= cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/iam v1.4.1 h1:cFC25Nv+u5BkTR/BT1tXdoF2daiVbZ1RLx2eqfQ9RMM= cloud.google.com/go/iam v1.4.1/go.mod h1:2vUEJpUG3Q9p2UdsyksaKpDzlwOrnMzS30isdReIcLM= +cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= +cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc= cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA= +cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY= cloud.google.com/go/longrunning v0.6.5 h1:sD+t8DO8j4HKW4QfouCklg7ZC1qC4uzVZt8iz3uTW+Q= cloud.google.com/go/longrunning v0.6.5/go.mod h1:Et04XK+0TTLKa5IPYryKf5DkpwImy6TluQ1QTLwlKmY= +cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= cloud.google.com/go/monitoring v1.24.0 h1:csSKiCJ+WVRgNkRzzz3BPoGjFhjPY23ZTcaenToJxMM= cloud.google.com/go/monitoring v1.24.0/go.mod h1:Bd1PRK5bmQBQNnuGwHBfUamAV1ys9049oEPHnn4pcsc= +cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= +cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= cloud.google.com/go/storage v1.51.0 h1:ZVZ11zCiD7b3k+cH5lQs/qcNaoSz3U9I0jgwVzqDlCw= cloud.google.com/go/storage v1.51.0/go.mod h1:YEJfu/Ki3i5oHC/7jyTgsGZwdQ8P9hqMqvpi5kRKGgc= +cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KMOg= +cloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk= cloud.google.com/go/trace v1.11.3 h1:c+I4YFjxRQjvAhRmSsmjpASUKq88chOX854ied0K/pE= cloud.google.com/go/trace v1.11.3/go.mod h1:pt7zCYiDSQjC9Y2oqCsh9jF4GStB/hmjrYLsxRR27q8= +cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0 h1:OqVGm6Ei3x5+yZmSJG1Mh2NwHvpVmZ08CB5qJhT9Nuk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0/go.mod h1:SZiPHWGOOk3bl8tkevxkoiwPgsIl6CwrWcbwjfHZpdM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 h1:6/0iUd0xrnX7qt+mLNRwg5c0PGv8wpE8K90ryANQwMI= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= @@ -132,8 +152,12 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.5 h1:VgzTY2jogw3xt39CusEnFJWm7rlsq5yL5q9XdLOuP5g= github.com/googleapis/enterprise-certificate-proxy v0.3.5/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= +github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= +github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= +github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -221,12 +245,17 @@ go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK2 go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.29.0 h1:WDdP9acbMYjbKIyJUhTvtzj601sVJOqgWdUxSdR/Ysc= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.29.0/go.mod h1:BLbf7zbNIONBLPwvFnwNHGj4zge8uTCM/UPIVW1Mq2I= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0= go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= @@ -243,21 +272,31 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -267,6 +306,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -277,28 +318,44 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.224.0 h1:Ir4UPtDsNiwIOHdExr3fAj4xZ42QjK7uQte3lORLJwU= google.golang.org/api v0.224.0/go.mod h1:3V39my2xAGkodXy0vEqcEtkqgw2GtrFL5WuBZlCTCOQ= +google.golang.org/api v0.271.0 h1:cIPN4qcUc61jlh7oXu6pwOQqbJW2GqYh5PS6rB2C/JY= +google.golang.org/api v0.271.0/go.mod h1:CGT29bhwkbF+i11qkRUJb2KMKqcJ1hdFceEIRd9u64Q= google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE= google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 h1:7ei4lp52gK1uSejlA8AZl5AJjeLUOHBQscRQZUgAcu0= +google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20/go.mod h1:ZdbssH/1SOVnjnDlXzxDHK2MCidiqXtbYccJNzNYPEE= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= From 1f1d5fc009816e2cb31a86142969976c55e74794 Mon Sep 17 00:00:00 2001 From: mikehquan19 Date: Thu, 19 Mar 2026 14:48:18 -0500 Subject: [PATCH 31/61] Use WithAuthCredentialsJSON since WithCredentialsJSON is deprecated due to security risk. WithCredentialsJSON doesn't validate the credential's configuration so it can be from untrusted source --- api/routes/storage.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/routes/storage.go b/api/routes/storage.go index b7088f29..8e56accb 100644 --- a/api/routes/storage.go +++ b/api/routes/storage.go @@ -36,11 +36,12 @@ func initStorageClient() *storage.Client { } else { // We're not running on the cloud, get JSON service account key from .env encodedCreds, exist := os.LookupEnv("GOOGLE_APPLICATION_CREDENTIALS") + jsonCredss := []byte(encodedCreds) if !exist { log.Println("Error loading 'GOOGLE_APPLICATION_CREDENTIALS' from the .env file, skipping cloud storage routes") return } - c, err = storage.NewClient(ctx, option.WithCredentialsJSON([]byte(encodedCreds))) + c, err = storage.NewClient(ctx, option.WithAuthCredentialsJSON(option.ServiceAccount, jsonCredss)) } if err != nil { log.Printf("Failed to create GCS client: %v", err) From dac098784d0274b5e66bc63a39f80528f3e3adb5 Mon Sep 17 00:00:00 2001 From: mikehquan19 Date: Thu, 19 Mar 2026 14:49:26 -0500 Subject: [PATCH 32/61] Update some doc --- .github/workflows/go.yml | 12 +++---- README.md | 5 ++- api/Makefile | 6 ++-- api/build.bat | 17 +++++----- api/go.sum | 67 +++------------------------------------- 5 files changed, 27 insertions(+), 80 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 44e04d45..ad4e25bd 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -34,14 +34,14 @@ jobs: - name: Check run: make check - - name: Build - run: make build - - name: Test env: MONGODB_URI: ${{ secrets.MONGODB_URI }} run: make test + - name: Build + run: make build + build_windows: runs-on: windows-latest defaults: @@ -67,10 +67,10 @@ jobs: - name: Check run: .\build.bat checks - - name: Build - run: .\build.bat build - - name: Test env: MONGODB_URI: ${{ secrets.MONGODB_URI }} run: .\build.bat test + + - name: Build + run: .\build.bat build diff --git a/README.md b/README.md index bc8cb372..b79cbffd 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Submit proposed changes via a [Pull Request](https://docs.github.com/en/pull-req ## Building ### Requirements -- [Golang 1.23 or Higher](https://go.dev/dl/) +- [Golang 1.24 or Higher](https://go.dev/dl/) ### Building for Windows cd into `nebula-api\api` @@ -56,6 +56,9 @@ Setup Go dependencies with Build with `make build` +If you run into `make: swag: No such file or directory`, make sure your GO Path is on the PATH environment, run +`export PATH=$(go env GOPATH)/bin:$PATH` + Run with `./go-api` diff --git a/api/Makefile b/api/Makefile index d03e8663..add78b94 100644 --- a/api/Makefile +++ b/api/Makefile @@ -17,14 +17,14 @@ check: gofmt -w ./.. goimports -w ./.. +test: + go test ./... -count=1 + build: server.go swag fmt swag init -g server.go --outputTypes yaml,go go build -o $(EXEC_NAME) server.go -test: - go test ./... - clean: $(EXEC_NAME) rm $(EXEC_NAME) diff --git a/api/build.bat b/api/build.bat index 615c63fc..c174700f 100644 --- a/api/build.bat +++ b/api/build.bat @@ -38,17 +38,18 @@ echo Checks done! echo[ if "%1"=="checks" exit +:test +echo Testing... +go test ./... -count=1 +if ERRORLEVEL 1 exit /b %ERRORLEVEL% :: fail if error occurred +echo Testing complete! +echo[ +if "%1"=="build" exit + :build echo Building... go build -o %EXEC_NAME% server.go if ERRORLEVEL 1 exit /b %ERRORLEVEL% :: fail if error occurred echo Build complete! echo[ -if "%1"=="build" exit - -:test -echo Testing... -go test ./... -if ERRORLEVEL 1 exit /b %ERRORLEVEL% :: fail if error occurred -echo Testing complete! -echo[ \ No newline at end of file +if "%1"=="build" exit \ No newline at end of file diff --git a/api/go.sum b/api/go.sum index 753d938c..905fd6d5 100644 --- a/api/go.sum +++ b/api/go.sum @@ -1,51 +1,31 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= -cloud.google.com/go v0.118.3 h1:jsypSnrE/w4mJysioGdMBg4MiW/hHx/sArFpaBWHdME= -cloud.google.com/go v0.118.3/go.mod h1:Lhs3YLnBlwJ4KA6nuObNMZ/fCbOQBPuWKPoE0Wa/9Vc= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= -cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps= -cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8= cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= -cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M= -cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -cloud.google.com/go/iam v1.4.1 h1:cFC25Nv+u5BkTR/BT1tXdoF2daiVbZ1RLx2eqfQ9RMM= -cloud.google.com/go/iam v1.4.1/go.mod h1:2vUEJpUG3Q9p2UdsyksaKpDzlwOrnMzS30isdReIcLM= cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= -cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc= -cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA= cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY= -cloud.google.com/go/longrunning v0.6.5 h1:sD+t8DO8j4HKW4QfouCklg7ZC1qC4uzVZt8iz3uTW+Q= -cloud.google.com/go/longrunning v0.6.5/go.mod h1:Et04XK+0TTLKa5IPYryKf5DkpwImy6TluQ1QTLwlKmY= +cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw= cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= -cloud.google.com/go/monitoring v1.24.0 h1:csSKiCJ+WVRgNkRzzz3BPoGjFhjPY23ZTcaenToJxMM= -cloud.google.com/go/monitoring v1.24.0/go.mod h1:Bd1PRK5bmQBQNnuGwHBfUamAV1ys9049oEPHnn4pcsc= +cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= -cloud.google.com/go/storage v1.51.0 h1:ZVZ11zCiD7b3k+cH5lQs/qcNaoSz3U9I0jgwVzqDlCw= -cloud.google.com/go/storage v1.51.0/go.mod h1:YEJfu/Ki3i5oHC/7jyTgsGZwdQ8P9hqMqvpi5kRKGgc= cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KMOg= cloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk= -cloud.google.com/go/trace v1.11.3 h1:c+I4YFjxRQjvAhRmSsmjpASUKq88chOX854ied0K/pE= -cloud.google.com/go/trace v1.11.3/go.mod h1:pt7zCYiDSQjC9Y2oqCsh9jF4GStB/hmjrYLsxRR27q8= cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= +cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0 h1:OqVGm6Ei3x5+yZmSJG1Mh2NwHvpVmZ08CB5qJhT9Nuk= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0/go.mod h1:SZiPHWGOOk3bl8tkevxkoiwPgsIl6CwrWcbwjfHZpdM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 h1:6/0iUd0xrnX7qt+mLNRwg5c0PGv8wpE8K90ryANQwMI= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= @@ -150,12 +130,8 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.5 h1:VgzTY2jogw3xt39CusEnFJWm7rlsq5yL5q9XdLOuP5g= -github.com/googleapis/enterprise-certificate-proxy v0.3.5/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= -github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= -github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -243,19 +219,14 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE= go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.29.0 h1:WDdP9acbMYjbKIyJUhTvtzj601sVJOqgWdUxSdR/Ysc= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.29.0/go.mod h1:BLbf7zbNIONBLPwvFnwNHGj4zge8uTCM/UPIVW1Mq2I= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk= go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= @@ -270,31 +241,21 @@ golang.org/x/arch v0.21.0 h1:iTC9o7+wP6cPWpDWkivCvQFGAHDQ59SrSxsLPcnkArw= golang.org/x/arch v0.21.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= -golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -304,8 +265,6 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -316,44 +275,28 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= -golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= -golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.224.0 h1:Ir4UPtDsNiwIOHdExr3fAj4xZ42QjK7uQte3lORLJwU= -google.golang.org/api v0.224.0/go.mod h1:3V39my2xAGkodXy0vEqcEtkqgw2GtrFL5WuBZlCTCOQ= google.golang.org/api v0.271.0 h1:cIPN4qcUc61jlh7oXu6pwOQqbJW2GqYh5PS6rB2C/JY= google.golang.org/api v0.271.0/go.mod h1:CGT29bhwkbF+i11qkRUJb2KMKqcJ1hdFceEIRd9u64Q= -google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE= -google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE= google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 h1:7ei4lp52gK1uSejlA8AZl5AJjeLUOHBQscRQZUgAcu0= google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20/go.mod h1:ZdbssH/1SOVnjnDlXzxDHK2MCidiqXtbYccJNzNYPEE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From c693cf0517ad9006642c42a9700e3f5b4d7de9d5 Mon Sep 17 00:00:00 2001 From: mikehquan19 Date: Thu, 19 Mar 2026 15:27:27 -0500 Subject: [PATCH 33/61] Upgrade the pipeline --- .github/workflows/go.yml | 6 +++--- api/Dockerfile | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index ad4e25bd..a01a5808 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -20,12 +20,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v6 with: - go-version: '1.24' + go-version: '1.25' cache-dependency-path: "**/go.sum" - name: Setup diff --git a/api/Dockerfile b/api/Dockerfile index 962baf7d..32f717ec 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1 -FROM docker.io/golang:1.24 AS builder +FROM docker.io/golang:1.25 AS builder WORKDIR /build COPY ./configs ./configs From fba7f538d54e9cd4df551611a5a13045c36e20c3 Mon Sep 17 00:00:00 2001 From: mikehquan19 Date: Thu, 19 Mar 2026 15:34:31 -0500 Subject: [PATCH 34/61] Some swag update --- api/controllers/discounts.go | 4 ++-- api/docs/docs.go | 12 +++--------- api/docs/swagger.yaml | 13 ++++--------- 3 files changed, 9 insertions(+), 20 deletions(-) diff --git a/api/controllers/discounts.go b/api/controllers/discounts.go index 7aa7ee33..684ffb74 100644 --- a/api/controllers/discounts.go +++ b/api/controllers/discounts.go @@ -24,13 +24,13 @@ var discountCategoriesOnce sync.Once // @Id discountPrograms // @Router /discountPrograms [get] // @Tags Discounts -// @Description "Returns list of discounts filtered using field-specific keyword searches or global fuzzy search." +// @Description "Returns list of discounts filtered using field-specific keyword searches or full-text search." // @Produce json // @Param category query string false "The discount's category (exact match with suggestions)." // @Param business query string false "The discount's business contains this keyword (case-insensitive)." // @Param address query string false "The discount's address contains this keyword (case-insensitive)." // @Param discount query string false "The discount's discount contains this keyword (case-insensitive)." -// @Param q query string false "Fuzzy search, must be used alone." +// @Param q query string false "Full-text search, must be used alone." // @Success 200 {object} schema.APIResponse[[]schema.DiscountProgram] "A list of discounts" // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 400 {object} schema.APIResponse[string] "A string describing the error" diff --git a/api/docs/docs.go b/api/docs/docs.go index 7f6c1ede..26ff7527 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -975,7 +975,7 @@ const docTemplate = `{ }, "/discountPrograms": { "get": { - "description": "\"Returns paginated list of discounts matching the query's string-typed key-value pairs. See offset for more details on pagination.\"", + "description": "\"Returns list of discounts filtered using field-specific keyword searches or full-text search.\"", "produces": [ "application/json" ], @@ -984,15 +984,9 @@ const docTemplate = `{ ], "operationId": "discountPrograms", "parameters": [ - { - "type": "number", - "description": "The starting position of the current page of discounts (e.g. For starting at the 17th discount, offset=16).", - "name": "offset", - "in": "query" - }, { "type": "string", - "description": "The discount's category.", + "description": "The discount's category (exact match with suggestions).", "name": "category", "in": "query" }, @@ -1016,7 +1010,7 @@ const docTemplate = `{ }, { "type": "string", - "description": "Full text search of all discount's fields.", + "description": "Full-text search, must be used alone.", "name": "q", "in": "query" } diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index 23990a36..59eb347a 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -1575,16 +1575,11 @@ paths: - Courses /discountPrograms: get: - description: '"Returns paginated list of discounts matching the query''s string-typed - key-value pairs. See offset for more details on pagination."' + description: '"Returns list of discounts filtered using field-specific keyword + searches or full-text search."' operationId: discountPrograms parameters: - - description: The starting position of the current page of discounts (e.g. - For starting at the 17th discount, offset=16). - in: query - name: offset - type: number - - description: The discount's category. + - description: The discount's category (exact match with suggestions). in: query name: category type: string @@ -1600,7 +1595,7 @@ paths: in: query name: discount type: string - - description: Full text search of all discount's fields. + - description: Full-text search, must be used alone. in: query name: q type: string From a2933e3cd6cbf06206b86d17f42310464de0a26d Mon Sep 17 00:00:00 2001 From: mikehquan19 Date: Thu, 19 Mar 2026 15:38:10 -0500 Subject: [PATCH 35/61] Yeah no newlines with error --- api/controllers/discounts.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/controllers/discounts.go b/api/controllers/discounts.go index 684ffb74..224398bd 100644 --- a/api/controllers/discounts.go +++ b/api/controllers/discounts.go @@ -145,7 +145,7 @@ func buildDiscountSearchQuery(p schema.DiscountQueryParams) (bson.M, error) { } } if !categoryFound { - return nil, fmt.Errorf("unknown category, valid categories are %s.\n", discountCategories) + return nil, fmt.Errorf("unknown category, valid categories are %s.", discountCategories) } } From 79fd3f05ae1a0f5cd23a49f4c9810d6f5255f861 Mon Sep 17 00:00:00 2001 From: mikehquan19 Date: Thu, 19 Mar 2026 15:41:50 -0500 Subject: [PATCH 36/61] No . with error --- api/controllers/discounts.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/controllers/discounts.go b/api/controllers/discounts.go index 224398bd..512b4d3b 100644 --- a/api/controllers/discounts.go +++ b/api/controllers/discounts.go @@ -145,7 +145,7 @@ func buildDiscountSearchQuery(p schema.DiscountQueryParams) (bson.M, error) { } } if !categoryFound { - return nil, fmt.Errorf("unknown category, valid categories are %s.", discountCategories) + return nil, fmt.Errorf("unknown category, valid categories are %s", discountCategories) } } From fbcb1cf249bf6de934427d82bdfa16bfcbfd5d83 Mon Sep 17 00:00:00 2001 From: SoggyRihno <94922205+SoggyRihno@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:16:07 -0500 Subject: [PATCH 37/61] Minor documentation changes, update cache to use type as key, not type string, added test to increase coverage --- api/schema/filter.go | 15 ++++++++++----- api/schema/filter_test.go | 36 ++++++++++++++++++++++-------------- 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/api/schema/filter.go b/api/schema/filter.go index a5ab44ec..bfc21b69 100644 --- a/api/schema/filter.go +++ b/api/schema/filter.go @@ -24,6 +24,7 @@ var ( ) // FilterQuery converts URL query parameters into a MongoDB BSON query filter. +// // It validates that each query parameter corresponds to a field in type F that is // marked as queryable. // @@ -62,12 +63,12 @@ func FilterQuery[F any](urlValues url.Values) (bson.M, error) { // loadQueryable returns a map indicating which fields of the given type are queryable. func loadQueryable(t reflect.Type) (map[string]bool, error) { - if cached, ok := queryableCache.Load(t.String()); ok { + if cached, ok := queryableCache.Load(t); ok { //should literally never fail but its best practice to check casts if queryMap, ok := cached.(map[string]bool); ok { return queryMap, nil } - queryableCache.Delete(t.String()) + queryableCache.Delete(t) return nil, fmt.Errorf("queryableCache was corrupted: %s was not of type map[string]bool", t.String()) } @@ -76,15 +77,16 @@ func loadQueryable(t reflect.Type) (map[string]bool, error) { return nil, err } - actual, _ := queryableCache.LoadOrStore(t.String(), queryable) + actual, _ := queryableCache.LoadOrStore(t, queryable) if queryMap, ok := actual.(map[string]bool); ok { return queryMap, nil } - queryableCache.Delete(t.String()) + queryableCache.Delete(t) return nil, fmt.Errorf("queryableCache was corrupted: %s was not of type map[string]bool", t.String()) } // recBuild recursively traverses a struct type to build a map of queryable fields. +// // It constructs dot-notation paths for nested fields and determines whether each field // can be used for filtering based on the "queryable" tag. func recBuild(t reflect.Type, prefix string, queryableMap map[string]bool, visited []reflect.Type) error { @@ -118,7 +120,7 @@ func recBuild(t reflect.Type, prefix string, queryableMap map[string]bool, visit _, queryable := field.Tag.Lookup("queryable") if fieldType.Kind() == reflect.Struct { if queryable { - // do not recurse into time.Time + // don't recurse into time.Time if _, ok := baseStruct[fieldType]; ok { queryableMap[fullPath] = true } else if err := recBuild(field.Type, fullPath, queryableMap, newVisited); err != nil { @@ -136,6 +138,9 @@ func recBuild(t reflect.Type, prefix string, queryableMap map[string]bool, visit // willCreateLoop determines if adding `value` to the `visited` list would create // a loop. +// +// Pointer types are allowed to recur once, i.e., only considered a loop when +// the same pointer type appears twice in a row. func willCreateLoop(visited []reflect.Type, value reflect.Type) bool { if value.Kind() != reflect.Ptr { return false diff --git a/api/schema/filter_test.go b/api/schema/filter_test.go index 02555fce..fbfc21f3 100644 --- a/api/schema/filter_test.go +++ b/api/schema/filter_test.go @@ -4,6 +4,7 @@ import ( "net/url" "reflect" "testing" + "time" "github.com/google/go-cmp/cmp" "go.mongodb.org/mongo-driver/bson" @@ -15,6 +16,11 @@ type _normal struct { Hidden bool `bson:"hidden" json:"hidden"` } +type _normalWithBaseStruct struct { + Name string `bson:"name" json:"name" queryable:""` + Time time.Time `bson:"time" json:"time" queryable:""` +} + type _missingJson struct { Name string Number int @@ -112,6 +118,17 @@ func TestFilterQuery(t *testing.T) { "number": "0", }, }, + "Normal with Base Struct": { + Function: FilterQuery[_normalWithBaseStruct], + UrlQuery: map[string][]string{ + "name": {"bob"}, + "time": {"2020-01-01T00:00:00Z"}, + }, + Expected: bson.M{ + "name": "bob", + "time": "2020-01-01T00:00:00Z", + }, + }, "Nested": { Function: FilterQuery[_nested], UrlQuery: map[string][]string{ @@ -155,12 +172,10 @@ func TestFilterQuery(t *testing.T) { for name, tc := range testCases { t.Run(name, func(t *testing.T) { result, err := tc.Function(tc.UrlQuery) - if tc.Fail { if err == nil { - t.Fatal("expected error but got nil") + t.Errorf("expected error but got nil") } - } else { if err != nil { t.Errorf("unexpected error %v ", err) @@ -170,12 +185,6 @@ func TestFilterQuery(t *testing.T) { t.Errorf("Failed (-expected +got)\n %s", diff) } } - - if tc.Fail && err == nil { - t.Errorf("expected error, got nil") - } else if !tc.Fail && err != nil { - t.Errorf("unexpected error %v ", err) - } }) } @@ -329,17 +338,16 @@ func TestLoadQueryable(t *testing.T) { t.Run("Cache Corruption", func(t *testing.T) { rType := reflect.TypeFor[_normal]() - typeName := rType.String() t.Run("Corrupted on Load", func(t *testing.T) { - queryableCache.Store(typeName, 14) + queryableCache.Store(rType, 14) _, err := loadQueryable(rType) if err == nil { t.Fatal("expected error when cache contains wrong type") } - if _, exists := queryableCache.Load(typeName); exists { + if _, exists := queryableCache.Load(rType); exists { t.Error("corrupted cache entry should have been deleted") } @@ -349,13 +357,13 @@ func TestLoadQueryable(t *testing.T) { }) t.Run("Recovery After Corruption", func(t *testing.T) { - queryableCache.Store(typeName, "wrong type") + queryableCache.Store(rType, "wrong type") if _, err := loadQueryable(reflect.TypeFor[_normal]()); err == nil { t.Fatal("expected corruption error") } - // first will load, second will be cached + // the first will load, the second will be cached for range 2 { if _, err := loadQueryable(reflect.TypeFor[_normal]()); err != nil { t.Fatalf("should recover after corruption: %v", err) From 1e7c655ba2b9272161d6d379a10e5c5cdc85418e Mon Sep 17 00:00:00 2001 From: mikehquan19 Date: Wed, 25 Mar 2026 13:22:46 -0500 Subject: [PATCH 38/61] Use the context that is tied to the request instead of creating another --- api/controllers/astra.go | 6 +++--- api/controllers/autocomplete.go | 2 +- api/controllers/calendar.go | 6 +++--- api/controllers/club.go | 4 ++-- api/controllers/course.go | 13 +++++-------- api/controllers/discounts.go | 3 ++- api/controllers/events.go | 8 ++++---- api/controllers/grades.go | 2 +- api/controllers/mazevo.go | 2 +- api/controllers/professor.go | 8 ++++---- api/controllers/rooms.go | 2 +- api/controllers/section.go | 8 ++++---- api/controllers/trends.go | 2 +- 13 files changed, 32 insertions(+), 34 deletions(-) diff --git a/api/controllers/astra.go b/api/controllers/astra.go index 542e5dbf..ddf80304 100644 --- a/api/controllers/astra.go +++ b/api/controllers/astra.go @@ -28,7 +28,7 @@ var astraCollection *mongo.Collection = configs.GetCollection("astra") // @Success 200 {object} schema.APIResponse[schema.MultiBuildingEvents[schema.AstraEvent]] "All AstraEvents with events on the inputted date" // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" func AstraEvents(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() date := c.Param("date") @@ -61,7 +61,7 @@ func AstraEvents(c *gin.Context) { // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 404 {object} schema.APIResponse[string] "A string describing the error" func AstraEventsByBuilding(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() date := c.Param("date") @@ -120,7 +120,7 @@ func AstraEventsByBuilding(c *gin.Context) { // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 404 {object} schema.APIResponse[string] "A string describing the error" func AstraEventsByBuildingAndRoom(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() date := c.Param("date") diff --git a/api/controllers/autocomplete.go b/api/controllers/autocomplete.go index a4c0429b..a975798f 100644 --- a/api/controllers/autocomplete.go +++ b/api/controllers/autocomplete.go @@ -25,7 +25,7 @@ var DAGCollection *mongo.Collection = configs.GetCollection("DAG") // @Success 200 {object} schema.APIResponse[[]schema.Autocomplete] "An aggregation of courses for use in generating autocomplete DAGs" // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" func AutocompleteDAG(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) defer cancel() var autocompleteDAG []schema.Autocomplete diff --git a/api/controllers/calendar.go b/api/controllers/calendar.go index 40407ebb..13536542 100644 --- a/api/controllers/calendar.go +++ b/api/controllers/calendar.go @@ -30,7 +30,7 @@ var cometCalendarCollection *mongo.Collection = configs.GetCollection("cometCale // @Success 200 {object} schema.APIResponse[schema.MultiBuildingEvents[schema.Event]] "All CometCalendarEvents with events on the inputted date" // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" func CometCalendarEvents(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() date := c.Param("date") @@ -63,7 +63,7 @@ func CometCalendarEvents(c *gin.Context) { // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 404 {object} schema.APIResponse[string] "A string describing the error" func CometCalendarEventsByBuilding(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() date := c.Param("date") @@ -135,7 +135,7 @@ func CometCalendarEventsByBuilding(c *gin.Context) { // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 404 {object} schema.APIResponse[string] "A string describing the error" func CometCalendarEventsByBuildingAndRoom(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() date := c.Param("date") diff --git a/api/controllers/club.go b/api/controllers/club.go index 103c5a1d..b904e070 100644 --- a/api/controllers/club.go +++ b/api/controllers/club.go @@ -23,7 +23,7 @@ import ( // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 400 {object} schema.APIResponse[string] "A string describing the error" func ClubDirectoryInfo(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() var clubsDatabase *sql.DB = configs.ConnectClubsDB() @@ -88,7 +88,7 @@ func ClubDirectoryInfo(c *gin.Context) { // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 400 {object} schema.APIResponse[string] "A string describing the error" func ClubSearch(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() var clubsDatabase *sql.DB = configs.ConnectClubsDB() diff --git a/api/controllers/course.go b/api/controllers/course.go index 1cb12450..18782cb7 100644 --- a/api/controllers/course.go +++ b/api/controllers/course.go @@ -41,10 +41,7 @@ var courseCollection *mongo.Collection = configs.GetCollection("courses") // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 400 {object} schema.APIResponse[string] "A string describing the error" func CourseSearch(c *gin.Context) { - //name := c.Query("name") // value of specific query parameter: string - //queryParams := c.Request.URL.Query() // map of all query params: map[string][]string - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() var courses []schema.Course @@ -122,7 +119,7 @@ func CourseById(c *gin.Context) { // @Success 200 {object} schema.APIResponse[[]schema.Course] "All courses" // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" func CourseAll(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) defer cancel() var courses []schema.Course @@ -223,7 +220,7 @@ func CourseProfessorById(c *gin.Context) { // courseAggregate is a generic function that gets a specified field of the courses, filters depending on the flag func courseAggregate[T any](flag string, c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() var queryResults []T @@ -243,6 +240,7 @@ func courseAggregate[T any](flag string, c *gin.Context) { } // Determine the endpoint based on the type of the desired query results + var zero T var endpoint string switch any(zero).(type) { @@ -295,7 +293,7 @@ func buildCoursePipeline(endpoint string, courseQuery bson.M, paginate map[strin var lookupStages, dedupStages mongo.Pipeline switch endpoint { case "sections": - lookupStages, dedupStages = mongo.Pipeline{}, mongo.Pipeline{} + // No extra stages middle stages case "professors": // Lookup the list of professors from the list of sections @@ -336,7 +334,6 @@ func buildCoursePipeline(endpoint string, courseQuery bson.M, paginate map[strin middleStages := append(append(lookupStages, replaceStages...), dedupStages...) paginateStages := mongo.Pipeline{ - // Keep order deterministic between calls bson.D{{Key: "$sort", Value: bson.D{{Key: "_id", Value: 1}}}}, paginate["latter_offset"], diff --git a/api/controllers/discounts.go b/api/controllers/discounts.go index 512b4d3b..7fd21006 100644 --- a/api/controllers/discounts.go +++ b/api/controllers/discounts.go @@ -36,7 +36,8 @@ var discountCategoriesOnce sync.Once // @Failure 400 {object} schema.APIResponse[string] "A string describing the error" func DiscountSearch(c *gin.Context) { fetchDiscountCategories() - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() var cursor *mongo.Cursor diff --git a/api/controllers/events.go b/api/controllers/events.go index 68247ced..eef1896c 100644 --- a/api/controllers/events.go +++ b/api/controllers/events.go @@ -29,7 +29,7 @@ var eventsCollection *mongo.Collection = configs.GetCollection("events") // @Success 200 {object} schema.APIResponse[schema.MultiBuildingEvents[schema.SectionWithTime]] "All sections with meetings on the specified date" // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" func Events(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) date := c.Param("date") @@ -62,7 +62,7 @@ func Events(c *gin.Context) { // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 404 {object} schema.APIResponse[string] "A string describing the error" func EventsByBuilding(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() date := c.Param("date") @@ -120,7 +120,7 @@ func EventsByBuilding(c *gin.Context) { // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 404 {object} schema.APIResponse[string] "A string describing the error" func EventsByRoom(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() date := c.Param("date") @@ -191,7 +191,7 @@ func EventsByRoom(c *gin.Context) { // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 404 {object} schema.APIResponse[string] "A string describing the error" func SectionsByRoomDetailed(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() date := c.Param("date") diff --git a/api/controllers/grades.go b/api/controllers/grades.go index 62c7ca0b..2919b6eb 100644 --- a/api/controllers/grades.go +++ b/api/controllers/grades.go @@ -165,7 +165,7 @@ func gradesAggregation(flag string, c *gin.Context) { var err error - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() // @TODO: Recommend forcing using first_name and last_name to ensure single professors per query. diff --git a/api/controllers/mazevo.go b/api/controllers/mazevo.go index a2b9647f..5372c172 100644 --- a/api/controllers/mazevo.go +++ b/api/controllers/mazevo.go @@ -26,7 +26,7 @@ var mazevoCollection *mongo.Collection = configs.GetCollection("mazevo") // @Success 200 {object} schema.APIResponse[schema.MultiBuildingEvents[schema.MazevoEvent]] "All MazevoEvents with events on the inputted date" // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" func MazevoEvents(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() date := c.Param("date") diff --git a/api/controllers/professor.go b/api/controllers/professor.go index 28c25044..c61bdd7a 100644 --- a/api/controllers/professor.go +++ b/api/controllers/professor.go @@ -56,7 +56,7 @@ func ProfessorSearch(c *gin.Context) { //name := c.Query("name") // value of specific query parameter: string //queryParams := c.Request.URL.Query() // map of all query params: map[string][]string - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() var professors []schema.Professor @@ -100,7 +100,7 @@ func ProfessorSearch(c *gin.Context) { // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 400 {object} schema.APIResponse[string] "A string describing the error" func ProfessorById(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() var professor schema.Professor @@ -134,7 +134,7 @@ func ProfessorById(c *gin.Context) { // @Success 200 {object} schema.APIResponse[[]schema.Professor] "All professors" // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" func ProfessorAll(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) defer cancel() var professors []schema.Professor @@ -250,7 +250,7 @@ func ProfessorSectionById(c *gin.Context) { // Get data for professor aggregate endpoints depending on flag func professorAggregate[T any](flag string, c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() var profAggregate []T diff --git a/api/controllers/rooms.go b/api/controllers/rooms.go index d961d3bb..4aba7943 100644 --- a/api/controllers/rooms.go +++ b/api/controllers/rooms.go @@ -25,7 +25,7 @@ var buildingCollection *mongo.Collection = configs.GetCollection("rooms") // @Success 200 {object} schema.APIResponse[[]schema.BuildingRooms] "All schedulable rooms being used in the current and futures semesters from CourseBook, Astra, and Mazevo" // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" func Rooms(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() var buildingRooms []schema.BuildingRooms // buildings and rooms to be returned diff --git a/api/controllers/section.go b/api/controllers/section.go index 0adbda4a..c70b2342 100644 --- a/api/controllers/section.go +++ b/api/controllers/section.go @@ -52,7 +52,7 @@ func SectionSearch(c *gin.Context) { //name := c.Query("name") // value of specific query parameter: string //queryParams := c.Request.URL.Query() // map of all query params: map[string][]string - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() var sections []schema.Section @@ -96,7 +96,7 @@ func SectionSearch(c *gin.Context) { // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 400 {object} schema.APIResponse[string] "A string describing the error" func SectionById(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() var section schema.Section @@ -172,7 +172,7 @@ func SectionCourseById(c *gin.Context) { // Get an array of courses from sections, filtered based on the the flag func sectionCourse(flag string, c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() var sectionCourses []schema.Course @@ -311,7 +311,7 @@ func SectionProfessorById(c *gin.Context) { // Get an array of professors from sections, func sectionProfessor(flag string, c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() var sectionProfessors []schema.Professor diff --git a/api/controllers/trends.go b/api/controllers/trends.go index a959c684..74cd9763 100644 --- a/api/controllers/trends.go +++ b/api/controllers/trends.go @@ -43,7 +43,7 @@ func TrendsProfessorSectionSearch(c *gin.Context) { // Reduce the repetitiveness of routes whose aggregation behaviors are identical. // This is subject to change as requests might be more complex. func trendsSectionSearch(flag string, c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() var detailedSections []schema.Section From afeedc05080b3f9ad0420688b71faa0cd0051f27 Mon Sep 17 00:00:00 2001 From: bvaic Date: Fri, 27 Mar 2026 01:48:39 -0500 Subject: [PATCH 39/61] changed DiscountProgram.Address type to []string allowing for multiple addresses to be stored as individual strings rather than one long string of addresses --- api/docs/docs.go | 5 ++++- api/docs/swagger.yaml | 4 +++- api/schema/objects.go | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/api/docs/docs.go b/api/docs/docs.go index 26ff7527..c0ab20e7 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -3940,7 +3940,10 @@ const docTemplate = `{ "type": "string" }, "address": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } }, "business": { "type": "string" diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index 59eb347a..4f790238 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -502,7 +502,9 @@ definitions: _id: type: string address: - type: string + items: + type: string + type: array business: type: string category: diff --git a/api/schema/objects.go b/api/schema/objects.go index a869ab80..49e45b94 100644 --- a/api/schema/objects.go +++ b/api/schema/objects.go @@ -129,7 +129,7 @@ type DiscountProgram struct { Id primitive.ObjectID `bson:"_id" json:"_id"` Category string `bson:"category" json:"category"` Business string `bson:"business" json:"business"` - Address string `bson:"address" json:"address"` + Address []string `bson:"address" json:"address"` Phone string `bson:"phone" json:"phone"` Email string `bson:"email" json:"email"` Website string `bson:"website" json:"website"` From 8ea59e741f6d1253bd632b27fe228a0fa5f05b8c Mon Sep 17 00:00:00 2001 From: Justin Date: Mon, 30 Mar 2026 11:51:35 -0500 Subject: [PATCH 40/61] Remove smtp_from env variable --- api/.env.template | 1 - api/controllers/email.go | 12 ++++++------ api/routes/email.go | 13 ++++++------- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/api/.env.template b/api/.env.template index ba1c1102..671b733f 100644 --- a/api/.env.template +++ b/api/.env.template @@ -23,7 +23,6 @@ EMAIL_QUEUE_ROUTE_KEY= # For SMTP email sending SMTP_HOST=smtp.gmail.com SMTP_USERNAME= -SMTP_FROM= SMTP_PASSWORD= # For Email Google Cloud Task Queueing diff --git a/api/controllers/email.go b/api/controllers/email.go index 0dcdc56f..66957795 100644 --- a/api/controllers/email.go +++ b/api/controllers/email.go @@ -22,11 +22,11 @@ func getEmailClient(c *gin.Context) *mail.Client { return val.(*mail.Client) } -// Get email from address from routes -func getEmailFrom(c *gin.Context) string { - val, exists := c.Get("emailFrom") +// Get email username from routes +func getEmailUsername(c *gin.Context) string { + val, exists := c.Get("emailUsername") if !exists { - panic("email from address not set in context") + panic("email username not set in context") } return val.(string) } @@ -78,10 +78,10 @@ func SendEmail(c *gin.Context) { } client := getEmailClient(c) - smtpFrom := getEmailFrom(c) + smtpUsername := getEmailUsername(c) m := mail.NewMsg() - if err := m.FromFormat(req.From, smtpFrom); err != nil { + if err := m.FromFormat(req.From, smtpUsername); err != nil { respond(c, http.StatusInternalServerError, "failed to set from address", err.Error()) return } diff --git a/api/routes/email.go b/api/routes/email.go index 0b17af22..e55071fa 100644 --- a/api/routes/email.go +++ b/api/routes/email.go @@ -16,7 +16,7 @@ import ( ) var emailClient *mail.Client -var smtpFromAddr string +var smtpUsername string var emailClientOnce sync.Once var tasksClient *cloudtasks.Client @@ -54,9 +54,8 @@ func initEmailClient() (*mail.Client, string) { smtpHost := os.Getenv("SMTP_HOST") smtpUser := os.Getenv("SMTP_USERNAME") smtpPass := os.Getenv("SMTP_PASSWORD") - smtpFrom := os.Getenv("SMTP_FROM") - if smtpHost == "" || smtpUser == "" || smtpPass == "" || smtpFrom == "" { + if smtpHost == "" || smtpUser == "" || smtpPass == "" { log.Println("SMTP environment variables are not fully configured; skipping email routes") return } @@ -73,13 +72,13 @@ func initEmailClient() (*mail.Client, string) { return } emailClient = c - smtpFromAddr = smtpFrom + smtpUsername = smtpUser }) - return emailClient, smtpFromAddr + return emailClient, smtpUsername } func EmailRoute(router *gin.Engine) { - client, fromAddr := initEmailClient() + client, username := initEmailClient() tClient, qPath, qUrl := initTasksClient() if client == nil { @@ -114,7 +113,7 @@ func EmailRoute(router *gin.Engine) { // Pass to next layer emailGroup.Use(func(c *gin.Context) { c.Set("emailClient", client) - c.Set("emailFrom", fromAddr) + c.Set("emailUsername", username) c.Set("tasksClient", tClient) c.Set("queuePath", qPath) c.Set("queueUrl", qUrl) From e4fcb6836f8f78ddfc091b3c98d52849a73f3014 Mon Sep 17 00:00:00 2001 From: Justin Date: Mon, 30 Mar 2026 11:55:48 -0500 Subject: [PATCH 41/61] Add missing headers to queueEmail --- api/controllers/email.go | 13 +++++++++++++ api/routes/email.go | 11 +++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/api/controllers/email.go b/api/controllers/email.go index 66957795..9c87baef 100644 --- a/api/controllers/email.go +++ b/api/controllers/email.go @@ -58,6 +58,15 @@ func getQueueUrl(c *gin.Context) string { return val.(string) } +// Get email send key from routes +func getEmailSendKey(c *gin.Context) string { + val, exists := c.Get("emailSendKey") + if !exists { + panic("email send key not set in context") + } + return val.(string) +} + // @Id sendEmail // @Router /email/send [post] // @Tags Internal @@ -148,6 +157,10 @@ func QueueEmail(c *gin.Context) { HttpRequest: &taskspb.HttpRequest{ HttpMethod: taskspb.HttpMethod_POST, Url: queueUrl, + Headers: map[string]string{ + "x-email-send-key": getEmailSendKey(c), + "x-api-key": c.GetHeader("x-api-key"), + }, }, }, }, diff --git a/api/routes/email.go b/api/routes/email.go index e55071fa..2831d5ee 100644 --- a/api/routes/email.go +++ b/api/routes/email.go @@ -17,6 +17,7 @@ import ( var emailClient *mail.Client var smtpUsername string +var emailSendKey string var emailClientOnce sync.Once var tasksClient *cloudtasks.Client @@ -48,14 +49,15 @@ func initTasksClient() (*cloudtasks.Client, string, string) { return tasksClient, queuePath, queueUrl } -func initEmailClient() (*mail.Client, string) { +func initEmailClient() (*mail.Client, string, string) { // Singleton to prevent multiple clients emailClientOnce.Do(func() { smtpHost := os.Getenv("SMTP_HOST") smtpUser := os.Getenv("SMTP_USERNAME") smtpPass := os.Getenv("SMTP_PASSWORD") + sendKey := os.Getenv("EMAIL_SEND_ROUTE_KEY") - if smtpHost == "" || smtpUser == "" || smtpPass == "" { + if smtpHost == "" || smtpUser == "" || smtpPass == "" || sendKey == "" { log.Println("SMTP environment variables are not fully configured; skipping email routes") return } @@ -74,11 +76,11 @@ func initEmailClient() (*mail.Client, string) { emailClient = c smtpUsername = smtpUser }) - return emailClient, smtpUsername + return emailClient, smtpUsername, emailSendKey } func EmailRoute(router *gin.Engine) { - client, username := initEmailClient() + client, username, emailSendKey := initEmailClient() tClient, qPath, qUrl := initTasksClient() if client == nil { @@ -114,6 +116,7 @@ func EmailRoute(router *gin.Engine) { emailGroup.Use(func(c *gin.Context) { c.Set("emailClient", client) c.Set("emailUsername", username) + c.Set("emailSendKey", emailSendKey) c.Set("tasksClient", tClient) c.Set("queuePath", qPath) c.Set("queueUrl", qUrl) From 1c1b8c675ba04650b348e1f44a62bf7729aee704 Mon Sep 17 00:00:00 2001 From: Krish-Patel656 Date: Thu, 19 Mar 2026 23:30:45 -0500 Subject: [PATCH 42/61] Revert "Implement pipline builder for section aggregate endpoints" This reverts commit a1112179d452f263159a69cbe73427d87328965e. --- api/controllers/section.go | 208 ++++++++++++++++++++++--------------- 1 file changed, 122 insertions(+), 86 deletions(-) diff --git a/api/controllers/section.go b/api/controllers/section.go index 641c6923..0adbda4a 100644 --- a/api/controllers/section.go +++ b/api/controllers/section.go @@ -65,7 +65,7 @@ func SectionSearch(c *gin.Context) { optionLimit, err := configs.GetOptionLimit(&query, c) if err != nil { - respond[string](c, http.StatusBadRequest, "offset is not type integer", err.Error()) + respond(c, http.StatusBadRequest, "offset is not type integer", err.Error()) return } @@ -83,7 +83,7 @@ func SectionSearch(c *gin.Context) { } // return result - respond[[]schema.Section](c, http.StatusOK, "success", sections) + respond(c, http.StatusOK, "success", sections) } // @Id sectionById @@ -119,7 +119,7 @@ func SectionById(c *gin.Context) { } // return result - respond[schema.Section](c, http.StatusOK, "success", section) + respond(c, http.StatusOK, "success", section) } // @Id sectionCourseSearch @@ -188,31 +188,77 @@ func sectionCourse(flag string, c *gin.Context) { return } - pipeline := buildSectionPipeline(sectionQuery, paginate, "courses", flag == "ById") - cursor, err := sectionCollection.Aggregate(ctx, pipeline) + // pipeline of query an array of courses from filtered sections + sectionCoursePipeline := mongo.Pipeline{ + // filter the sections + bson.D{{Key: "$match", Value: sectionQuery}}, + + // paginate the sections before pulling courses from those sections + paginate["former_offset"], + paginate["limit"], + + // lookup the course referenced by sections from the course collection + bson.D{ + {Key: "$lookup", Value: bson.D{ + {Key: "from", Value: "courses"}, + {Key: "localField", Value: "course_reference"}, + {Key: "foreignField", Value: "_id"}, + {Key: "as", Value: "course_reference"}, + }}, + }, + + // project to remove every other fields except for courses + bson.D{ + {Key: "$project", Value: bson.D{ + {Key: "courses", Value: "$course_reference"}, + }}, + }, + + // unwind the courses + bson.D{ + {Key: "$unwind", Value: bson.D{ + {Key: "path", Value: "$courses"}, + {Key: "preserveNullAndEmptyArrays", Value: false}, + }}, + }, + + // replace the combinations of id and course with courses entirely + bson.D{{Key: "$replaceWith", Value: "$courses"}}, + + // remove duplicate courses + bson.D{{Key: "$group", Value: bson.D{ + {Key: "_id", Value: "$_id"}, + {Key: "course", Value: bson.D{{Key: "$first", Value: "$$ROOT"}}}, + }}}, + bson.D{{Key: "$replaceWith", Value: "$course"}}, + // keep order deterministic between calls + bson.D{{Key: "$sort", Value: bson.D{{Key: "_id", Value: 1}}}}, + + // paginate the courses + paginate["latter_offset"], + paginate["limit"], + } + + cursor, err := sectionCollection.Aggregate(ctx, sectionCoursePipeline) if err != nil { respondWithInternalError(c, err) return } - if flag == "ById" { - var course schema.Course - if cursor.Next(ctx) { - if err := cursor.Decode(&course); err != nil { - respondWithInternalError(c, err) - return - } - respond[schema.Course](c, http.StatusOK, "success", course) - return - } - respond[interface{}](c, http.StatusOK, "success", nil) - } else { - if err := cursor.All(ctx, §ionCourses); err != nil { - respondWithInternalError(c, err) - return - } - respond[[]schema.Course](c, http.StatusOK, "success", sectionCourses) + // Parse the array of courses + if err = cursor.All(ctx, §ionCourses); err != nil { + respondWithInternalError(c, err) + return + } + + switch flag { + case "Search": + respond(c, http.StatusOK, "success", sectionCourses) + case "ById": + // Section is only referenced by only one course, so return a single course + respond(c, http.StatusOK, "success", sectionCourses[0]) } + } // @Id sectionProfessorSearch @@ -263,7 +309,7 @@ func SectionProfessorById(c *gin.Context) { sectionProfessor("ById", c) } -// Get an array of professors sections, +// Get an array of professors from sections, func sectionProfessor(flag string, c *gin.Context) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -281,79 +327,69 @@ func sectionProfessor(flag string, c *gin.Context) { return } - pipeline := buildSectionPipeline(sectionQuery, paginate, "professors", flag == "ById") - cursor, err := sectionCollection.Aggregate(ctx, pipeline) + // pipeline to query an array of professors from filtered sections + sectionProfessorPipeline := mongo.Pipeline{ + // filter the sections + bson.D{{Key: "$match", Value: sectionQuery}}, + // paginate the sections before pulling courses from those sections + paginate["former_offset"], + paginate["limit"], + + // lookup the professors referenced by sections from the course collection + bson.D{ + {Key: "$lookup", Value: bson.D{ + {Key: "from", Value: "professors"}, + {Key: "localField", Value: "professors"}, + {Key: "foreignField", Value: "_id"}, + {Key: "as", Value: "professors"}, + }}, + }, + + // project to remove every other fields except for professors + bson.D{ + {Key: "$project", Value: bson.D{ + {Key: "professors", Value: "$professors"}, + }}, + }, + + // unwind the professors + bson.D{ + {Key: "$unwind", Value: bson.D{ + {Key: "path", Value: "$professors"}, + {Key: "preserveNullAndEmptyArrays", Value: false}, + }}, + }, + + // replace the combinations of id and course with courses entirely + bson.D{{Key: "$replaceWith", Value: "$professors"}}, + + // remove duplicate professors + bson.D{{Key: "$group", Value: bson.D{ + {Key: "_id", Value: "$_id"}, + {Key: "professor", Value: bson.D{{Key: "$first", Value: "$$ROOT"}}}, + }}}, + bson.D{{Key: "$replaceWith", Value: "$professor"}}, + + // keep order deterministic between calls + bson.D{{Key: "$sort", Value: bson.D{{Key: "_id", Value: 1}}}}, + + // paginate the courses + paginate["latter_offset"], + paginate["limit"], + } + + cursor, err := sectionCollection.Aggregate(ctx, sectionProfessorPipeline) if err != nil { respondWithInternalError(c, err) return } - // Parse the array of professors + // Parse the array of courses if err = cursor.All(ctx, §ionProfessors); err != nil { respondWithInternalError(c, err) return } respond(c, http.StatusOK, "success", sectionProfessors) -} - -func buildSectionPipeline( - sectionQuery bson.M, - paginate map[string]bson.D, - lookupType string, - single bool, -) mongo.Pipeline { - localField := "course_reference" - field := lookupType - - if lookupType == "professors" { - localField = "professors" - } - pipeline := mongo.Pipeline{ - bson.D{{Key: "$match", Value: sectionQuery}}, - } - if !single { - pipeline = append(pipeline, - paginate["former_offset"], - paginate["limit"], - ) - } - - pipeline = append(pipeline, - bson.D{{Key: "$lookup", Value: bson.D{ - {Key: "from", Value: lookupType}, - {Key: "localField", Value: localField}, - {Key: "foreignField", Value: "_id"}, - {Key: "as", Value: field}, - }}}, - bson.D{{Key: "$project", Value: bson.D{{Key: field, Value: "$" + field}}}}, - ) - // unwind/replaceWith so the aggregation yields the joined document itself. - pipeline = append(pipeline, - bson.D{{Key: "$unwind", Value: bson.D{ - {Key: "path", Value: "$" + field}, - {Key: "preserveNullAndEmptyArrays", Value: false}, - }}}, - bson.D{{Key: "$replaceWith", Value: "$" + field}}, - ) - // remove duplicate courses - pipeline = append(pipeline, - bson.D{{Key: "$group", Value: bson.D{ - {Key: "_id", Value: "$_id"}, - {Key: "doc", Value: bson.D{{Key: "$first", Value: "$$ROOT"}}}, - }}}, - bson.D{{Key: "$replaceWith", Value: "$doc"}}, - ) - - // For non-single (search) requests, apply sorting and latter_offset pagination - // after unwinding so we return a paginated list of the joined documents. - if !single { - pipeline = append(pipeline, - bson.D{{Key: "$sort", Value: bson.D{{Key: "_id", Value: 1}}}}, - paginate["latter_offset"], - paginate["limit"], - ) - } - return pipeline } From 40bdb0db75d82ae9e1443b08fbfca57b12f2c972 Mon Sep 17 00:00:00 2001 From: Krish-Patel656 Date: Thu, 2 Apr 2026 14:56:51 -0500 Subject: [PATCH 43/61] Fix structure based on course and section files --- api/controllers/section.go | 168 ++++++++++++++----------------------- 1 file changed, 65 insertions(+), 103 deletions(-) diff --git a/api/controllers/section.go b/api/controllers/section.go index 0adbda4a..713d6c3a 100644 --- a/api/controllers/section.go +++ b/api/controllers/section.go @@ -49,9 +49,6 @@ var sectionCollection *mongo.Collection = configs.GetCollection("sections") // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 400 {object} schema.APIResponse[string] "A string describing the error" func SectionSearch(c *gin.Context) { - //name := c.Query("name") // value of specific query parameter: string - //queryParams := c.Request.URL.Query() // map of all query params: map[string][]string - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -188,64 +185,11 @@ func sectionCourse(flag string, c *gin.Context) { return } - // pipeline of query an array of courses from filtered sections - sectionCoursePipeline := mongo.Pipeline{ - // filter the sections - bson.D{{Key: "$match", Value: sectionQuery}}, - - // paginate the sections before pulling courses from those sections - paginate["former_offset"], - paginate["limit"], - - // lookup the course referenced by sections from the course collection - bson.D{ - {Key: "$lookup", Value: bson.D{ - {Key: "from", Value: "courses"}, - {Key: "localField", Value: "course_reference"}, - {Key: "foreignField", Value: "_id"}, - {Key: "as", Value: "course_reference"}, - }}, - }, - - // project to remove every other fields except for courses - bson.D{ - {Key: "$project", Value: bson.D{ - {Key: "courses", Value: "$course_reference"}, - }}, - }, - - // unwind the courses - bson.D{ - {Key: "$unwind", Value: bson.D{ - {Key: "path", Value: "$courses"}, - {Key: "preserveNullAndEmptyArrays", Value: false}, - }}, - }, - - // replace the combinations of id and course with courses entirely - bson.D{{Key: "$replaceWith", Value: "$courses"}}, - - // remove duplicate courses - bson.D{{Key: "$group", Value: bson.D{ - {Key: "_id", Value: "$_id"}, - {Key: "course", Value: bson.D{{Key: "$first", Value: "$$ROOT"}}}, - }}}, - bson.D{{Key: "$replaceWith", Value: "$course"}}, - - // keep order deterministic between calls - bson.D{{Key: "$sort", Value: bson.D{{Key: "_id", Value: 1}}}}, - - // paginate the courses - paginate["latter_offset"], - paginate["limit"], - } - - cursor, err := sectionCollection.Aggregate(ctx, sectionCoursePipeline) + cursor, err := sectionCollection.Aggregate(ctx, buildSectionPipeline("courses", sectionQuery, paginate)) if err != nil { respondWithInternalError(c, err) return } - // Parse the array of courses if err = cursor.All(ctx, §ionCourses); err != nil { respondWithInternalError(c, err) return @@ -255,10 +199,8 @@ func sectionCourse(flag string, c *gin.Context) { case "Search": respond(c, http.StatusOK, "success", sectionCourses) case "ById": - // Section is only referenced by only one course, so return a single course respond(c, http.StatusOK, "success", sectionCourses[0]) } - } // @Id sectionProfessorSearch @@ -327,69 +269,89 @@ func sectionProfessor(flag string, c *gin.Context) { return } - // pipeline to query an array of professors from filtered sections - sectionProfessorPipeline := mongo.Pipeline{ + cursor, err := sectionCollection.Aggregate(ctx, buildSectionPipeline("professors", sectionQuery, paginate)) + if err != nil { + respondWithInternalError(c, err) + return + } + if err = cursor.All(ctx, §ionProfessors); err != nil { + respondWithInternalError(c, err) + return + } + + respond(c, http.StatusOK, "success", sectionProfessors) +} + +// buildSectionPipeline builds the pipeline to aggregate courses or professors from filtered sections +func buildSectionPipeline(endpoint string, sectionQuery bson.M, paginate map[string]bson.D) mongo.Pipeline { + baseStages := mongo.Pipeline{ // filter the sections bson.D{{Key: "$match", Value: sectionQuery}}, // paginate the sections before pulling courses from those sections paginate["former_offset"], paginate["limit"], + } + + var lookupStages mongo.Pipeline + switch endpoint { + case "courses": + lookupStages = mongo.Pipeline{ + // lookup the course referenced by sections from the course collection + bson.D{{Key: "$lookup", Value: bson.D{ + {Key: "from", Value: "courses"}, + {Key: "localField", Value: "course_reference"}, + {Key: "foreignField", Value: "_id"}, + {Key: "as", Value: "courses"}, + }}}, + } - // lookup the professors referenced by sections from the course collection - bson.D{ - {Key: "$lookup", Value: bson.D{ + case "professors": + lookupStages = mongo.Pipeline{ + // lookup the professors referenced by sections from the course collection + bson.D{{Key: "$lookup", Value: bson.D{ {Key: "from", Value: "professors"}, {Key: "localField", Value: "professors"}, {Key: "foreignField", Value: "_id"}, {Key: "as", Value: "professors"}, - }}, - }, - - // project to remove every other fields except for professors - bson.D{ - {Key: "$project", Value: bson.D{ - {Key: "professors", Value: "$professors"}, - }}, - }, - - // unwind the professors - bson.D{ - {Key: "$unwind", Value: bson.D{ - {Key: "path", Value: "$professors"}, - {Key: "preserveNullAndEmptyArrays", Value: false}, - }}, - }, - - // replace the combinations of id and course with courses entirely - bson.D{{Key: "$replaceWith", Value: "$professors"}}, - - // remove duplicate professors + }}}, + } + + default: + panic("invalid endpoint for buildSectionPipeline: " + endpoint) + } + + replaceStages := mongo.Pipeline{ + // project to remove every other fields except for courses/professors + bson.D{{Key: "$project", Value: bson.D{ + {Key: endpoint, Value: "$" + endpoint}, + }}}, + + // unwind the courses/professors + bson.D{{Key: "$unwind", Value: bson.D{ + {Key: "path", Value: "$" + endpoint}, + {Key: "preserveNullAndEmptyArrays", Value: false}, + }}}, + + // replace the combinations of id and course/professor with courses/professors entirely + bson.D{{Key: "$replaceWith", Value: "$" + endpoint}}, + + // remove duplicate courses/professors bson.D{{Key: "$group", Value: bson.D{ {Key: "_id", Value: "$_id"}, - {Key: "professor", Value: bson.D{{Key: "$first", Value: "$$ROOT"}}}, + {Key: "item", Value: bson.D{{Key: "$first", Value: "$$ROOT"}}}, }}}, - bson.D{{Key: "$replaceWith", Value: "$professor"}}, + bson.D{{Key: "$replaceWith", Value: "$item"}}, + } + paginateStages := mongo.Pipeline{ // keep order deterministic between calls bson.D{{Key: "$sort", Value: bson.D{{Key: "_id", Value: 1}}}}, - // paginate the courses + // paginate the courses/professors paginate["latter_offset"], paginate["limit"], } - cursor, err := sectionCollection.Aggregate(ctx, sectionProfessorPipeline) - if err != nil { - respondWithInternalError(c, err) - return - } - // Parse the array of courses - if err = cursor.All(ctx, §ionProfessors); err != nil { - respondWithInternalError(c, err) - return - } - - respond(c, http.StatusOK, "success", sectionProfessors) - -} + return append(append(append(baseStages, lookupStages...), replaceStages...), paginateStages...) +} \ No newline at end of file From 6de7a4c0e2fb6d2a7ebcca154b266e8423ab3836 Mon Sep 17 00:00:00 2001 From: SiddhaarthB11 Date: Tue, 14 Apr 2026 02:13:52 -0500 Subject: [PATCH 44/61] feat: implement maximum upload size and fix storage routes --- api/.env.template | 1 + api/configs/env.go | 23 ++++++++++ api/controllers/storage.go | 72 ++++++++++++++++++++++-------- api/max_upload_size_test.go | 89 +++++++++++++++++++++++++++++++++++++ api/routes/storage.go | 7 ++- 5 files changed, 169 insertions(+), 23 deletions(-) create mode 100644 api/max_upload_size_test.go diff --git a/api/.env.template b/api/.env.template index 5566c341..1c4c82c6 100644 --- a/api/.env.template +++ b/api/.env.template @@ -12,6 +12,7 @@ CLUBS_DB_URI= # CLOUD STORAGE (internal use only) GOOGLE_APPLICATION_CREDENTIALS= STORAGE_ROUTE_KEY= +# MAX_UPLOAD_SIZE=104857600 # SENRTY SENTRY_ENVIRONMENT=development diff --git a/api/configs/env.go b/api/configs/env.go index d1017dd5..60cdfdb5 100644 --- a/api/configs/env.go +++ b/api/configs/env.go @@ -71,3 +71,26 @@ func GetEnvLimit() int64 { return limit } + +func GetEnvMaxUploadSize() int64 { + const ( + defaultLimit int64 = 30 * 1024 * 1024 + hardCapLimit int64 = 50 * 1024 * 1024 + ) + + limitString, exist := os.LookupEnv("MAX_UPLOAD_SIZE") + if !exist { + return defaultLimit + } + + limit, err := strconv.ParseInt(limitString, 10, 64) + if err != nil { + return defaultLimit + } + + if limit > hardCapLimit { + return hardCapLimit + } + + return limit +} diff --git a/api/controllers/storage.go b/api/controllers/storage.go index 96f9b659..9fa2c481 100644 --- a/api/controllers/storage.go +++ b/api/controllers/storage.go @@ -1,6 +1,7 @@ package controllers import ( + "bytes" "context" "errors" "fmt" @@ -13,11 +14,12 @@ import ( "github.com/gin-gonic/gin" "google.golang.org/api/iterator" + "github.com/UTDNebula/nebula-api/api/configs" "github.com/UTDNebula/nebula-api/api/schema" ) const ( - PROJECT_ID = "nebula-api-368223" + PROJECT_ID = "woven-alpha-489519-k4" ) // Get client from routes @@ -32,14 +34,17 @@ func getClient(c *gin.Context) *storage.Client { // Get bucket or create it if it doesn't already exist func getOrCreateBucket(client *storage.Client, bucket string) (*storage.BucketHandle, error) { ctx := context.Background() - // Get bucket, or create it if it does not exist - // NOTE: We automatically prefix bucket names with "utdnebula_" here since bucket names need to be GLOBALLY unique bucketHandle := client.Bucket(schema.BUCKET_PREFIX + bucket) _, err := bucketHandle.Attrs(ctx) if err != nil { - err = bucketHandle.Create(ctx, PROJECT_ID, nil) - if err != nil { - return nil, errors.New("failed to create bucket: " + err.Error()) + + if errors.Is(err, storage.ErrBucketNotExist) { + err = bucketHandle.Create(ctx, PROJECT_ID, nil) + if err != nil { + return nil, errors.New("failed to create bucket: " + err.Error()) + } + } else { + return nil, err } } return bucketHandle, nil @@ -203,6 +208,42 @@ func ObjectInfo(c *gin.Context) { func PostObject(c *gin.Context) { bucket := c.Param("bucket") objectID := c.Param("objectID") + + maxUploadSize := configs.GetEnvMaxUploadSize() + + // Force early 413 check via Content-Length if present + if c.Request.ContentLength > maxUploadSize { + respond(c, http.StatusRequestEntityTooLarge, "error", fmt.Sprintf("File too large. Maximum allowed size is %d bytes (%dMB)", maxUploadSize, maxUploadSize/(1024*1024))) + return + } + + // Use MaxBytesReader to limit the body + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadSize) + + tmpBuf := make([]byte, int(maxUploadSize)+1) + n, readErr := c.Request.Body.Read(tmpBuf) + + // Return 413 if file is too large + if readErr != nil { + var maxBytesErr *http.MaxBytesError + if errors.As(readErr, &maxBytesErr) { + respond(c, http.StatusRequestEntityTooLarge, "error", fmt.Sprintf("File too large. Maximum allowed size is %d bytes (%dMB)", maxUploadSize, maxUploadSize/(1024*1024))) + return + } + // If it's not EOF, it's a real error + if !errors.Is(readErr, io.EOF) { + respondWithInternalError(c, readErr) + return + } + } + + var fileReader io.Reader + if n > 0 { + fileReader = io.MultiReader(bytes.NewReader(tmpBuf[:n]), c.Request.Body) + } else { + fileReader = c.Request.Body + } + client := getClient(c) ctx := context.Background() @@ -212,14 +253,6 @@ func PostObject(c *gin.Context) { return } - // Read body as byte stream - fileReader := c.Request.Body - if fileReader == nil { - respond(c, http.StatusBadRequest, "error", "Empty body") - return - } - defer fileReader.Close() - objectHandle := bucketHandle.Object(objectID) if objectHandle == nil { respondWithInternalError(c, err) @@ -233,6 +266,11 @@ func PostObject(c *gin.Context) { // Upload if _, err := io.Copy(wc, fileReader); err != nil { + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + respond(c, http.StatusRequestEntityTooLarge, "error", fmt.Sprintf("File too large. Maximum allowed size is %d bytes (%dMB)", maxUploadSize, maxUploadSize/(1024*1024))) + return + } respondWithInternalError(c, err) return } @@ -251,11 +289,7 @@ func PostObject(c *gin.Context) { // Generate public URL escapedObject := url.PathEscape(objectID) - url := fmt.Sprintf( - "https://storage.googleapis.com/%s/%s", - schema.BUCKET_PREFIX+bucket, - escapedObject, - ) + url := fmt.Sprintf("https://storage.googleapis.com/%s/%s", schema.BUCKET_PREFIX+bucket, escapedObject) objectInfo := schema.ObjectInfoFromAttrs(attrs, url) respond(c, http.StatusOK, "success", objectInfo) diff --git a/api/max_upload_size_test.go b/api/max_upload_size_test.go new file mode 100644 index 00000000..f971511f --- /dev/null +++ b/api/max_upload_size_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "cloud.google.com/go/storage" + "github.com/UTDNebula/nebula-api/api/controllers" + "github.com/gin-gonic/gin" +) + +func TestMaxUploadSize(t *testing.T) { + // Set the environment variable for max upload size (e.g., 100 bytes) + os.Setenv("MAX_UPLOAD_SIZE", "100") + defer os.Unsetenv("MAX_UPLOAD_SIZE") + + // Setup Gin + gin.SetMode(gin.TestMode) + router := gin.New() + + router.POST("/storage/:bucket/:objectID", func(c *gin.Context) { + + c.Set("gcsClient", &storage.Client{}) + controllers.PostObject(c) + }) + + t.Run("Upload within limit", func(t *testing.T) { + + defer func() { + if r := recover(); r != nil { + + } + }() + + data := make([]byte, 50) + req, _ := http.NewRequest("POST", "/storage/test-bucket/small-file", bytes.NewBuffer(data)) + req.Header.Set("Content-Type", "application/octet-stream") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code == http.StatusRequestEntityTooLarge { + t.Errorf("Expected status NOT 413, got %d", w.Code) + } + }) + + t.Run("Upload exceeding limit via Content-Length", func(t *testing.T) { + data := make([]byte, 150) + req, _ := http.NewRequest("POST", "/storage/test-bucket/large-file", bytes.NewBuffer(data)) + req.Header.Set("Content-Type", "application/octet-stream") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusRequestEntityTooLarge { + t.Errorf("Expected status 413, got %d. Body: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "File too large") { + t.Errorf("Expected error message 'File too large', got %s", w.Body.String()) + } + }) + + t.Run("Upload exceeding limit via Stream", func(t *testing.T) { + pr, pw := io.Pipe() + go func() { + pw.Write(make([]byte, 150)) + pw.Close() + }() + + req, _ := http.NewRequest("POST", "/storage/test-bucket/stream-file", pr) + req.ContentLength = -1 + req.Header.Set("Content-Type", "application/octet-stream") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusRequestEntityTooLarge { + t.Errorf("Expected status 413, got %d. Body: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "File too large") { + t.Errorf("Expected error message 'File too large', got %s", w.Body.String()) + } + }) +} diff --git a/api/routes/storage.go b/api/routes/storage.go index 8e56accb..f5504058 100644 --- a/api/routes/storage.go +++ b/api/routes/storage.go @@ -30,21 +30,20 @@ func initStorageClient() *storage.Client { var err error _, exist := os.LookupEnv("USE_CLOUD_CREDS") - // If USE_CLOUD_CREDS env var set, assume we're running on cloud and don't need to set creds if exist { c, err = storage.NewClient(ctx) } else { // We're not running on the cloud, get JSON service account key from .env encodedCreds, exist := os.LookupEnv("GOOGLE_APPLICATION_CREDENTIALS") - jsonCredss := []byte(encodedCreds) if !exist { log.Println("Error loading 'GOOGLE_APPLICATION_CREDENTIALS' from the .env file, skipping cloud storage routes") return } - c, err = storage.NewClient(ctx, option.WithAuthCredentialsJSON(option.ServiceAccount, jsonCredss)) + jsonCreds := []byte(encodedCreds) + c, err = storage.NewClient(ctx, option.WithAuthCredentialsJSON(option.ServiceAccount, jsonCreds)) } if err != nil { - log.Printf("Failed to create GCS client: %v", err) + log.Printf("Error initializing GCS client: %v", err) return } client = c From a9a40b3e5a867cdb3bfee3ab926339587484cb54 Mon Sep 17 00:00:00 2001 From: kimbow231 Date: Tue, 14 Apr 2026 23:46:31 -0500 Subject: [PATCH 45/61] Added url field to AcademicCalendar schema Co-authored-by: bvaic vaiciulis.ben@gmail.com --- api/schema/objects.go | 1 + 1 file changed, 1 insertion(+) diff --git a/api/schema/objects.go b/api/schema/objects.go index 49e45b94..170654a2 100644 --- a/api/schema/objects.go +++ b/api/schema/objects.go @@ -328,6 +328,7 @@ type AcademicCalendar struct { MidtermsDue string `bson:"midterms_due" json:"midterms_due"` UniversityClosings [][]string `bson:"university_closings" json:"university_closings"` NoClasses [][]string `bson:"no_classes" json:"no_classes"` + URL string `bson: "url" json: "url"` } type AcademicCalendarSession struct { Name string `bson:"name" json:"name"` From 7f1bd48aca1a28994fd521da78a19e5b80316bd3 Mon Sep 17 00:00:00 2001 From: kimbow231 Date: Wed, 15 Apr 2026 22:47:57 -0500 Subject: [PATCH 46/61] fixed formating error --- api/schema/objects.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/schema/objects.go b/api/schema/objects.go index 170654a2..ff80e04c 100644 --- a/api/schema/objects.go +++ b/api/schema/objects.go @@ -328,7 +328,7 @@ type AcademicCalendar struct { MidtermsDue string `bson:"midterms_due" json:"midterms_due"` UniversityClosings [][]string `bson:"university_closings" json:"university_closings"` NoClasses [][]string `bson:"no_classes" json:"no_classes"` - URL string `bson: "url" json: "url"` + URL string `bson:"url" json:"url"` } type AcademicCalendarSession struct { Name string `bson:"name" json:"name"` From ff1fbfd6d400d1b05bf280547ae6888da8be18c9 Mon Sep 17 00:00:00 2001 From: SiddhaarthB11 Date: Sat, 18 Apr 2026 22:18:11 -0500 Subject: [PATCH 47/61] signed-url-upload-size-changes --- api/controllers/storage.go | 49 ++++++++++++++++++++++---------------- api/routes/storage.go | 1 + 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/api/controllers/storage.go b/api/controllers/storage.go index 9fa2c481..65e7ebe4 100644 --- a/api/controllers/storage.go +++ b/api/controllers/storage.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/url" + "strings" "time" "cloud.google.com/go/storage" @@ -19,7 +20,7 @@ import ( ) const ( - PROJECT_ID = "woven-alpha-489519-k4" + PROJECT_ID = "nebula-api-368223" ) // Get client from routes @@ -34,6 +35,8 @@ func getClient(c *gin.Context) *storage.Client { // Get bucket or create it if it doesn't already exist func getOrCreateBucket(client *storage.Client, bucket string) (*storage.BucketHandle, error) { ctx := context.Background() + // Get bucket, or create it if it does not exist + // NOTE: We automatically prefix bucket names with "utdnebula_" here since bucket names need to be GLOBALLY unique bucketHandle := client.Bucket(schema.BUCKET_PREFIX + bucket) _, err := bucketHandle.Attrs(ctx) if err != nil { @@ -220,29 +223,20 @@ func PostObject(c *gin.Context) { // Use MaxBytesReader to limit the body c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadSize) - tmpBuf := make([]byte, int(maxUploadSize)+1) - n, readErr := c.Request.Body.Read(tmpBuf) + // Read and validate the entire (capped) request body before touching GCS. - // Return 413 if file is too large + fileBytes, readErr := io.ReadAll(c.Request.Body) if readErr != nil { var maxBytesErr *http.MaxBytesError if errors.As(readErr, &maxBytesErr) { respond(c, http.StatusRequestEntityTooLarge, "error", fmt.Sprintf("File too large. Maximum allowed size is %d bytes (%dMB)", maxUploadSize, maxUploadSize/(1024*1024))) return } - // If it's not EOF, it's a real error - if !errors.Is(readErr, io.EOF) { - respondWithInternalError(c, readErr) - return - } + respondWithInternalError(c, readErr) + return } - var fileReader io.Reader - if n > 0 { - fileReader = io.MultiReader(bytes.NewReader(tmpBuf[:n]), c.Request.Body) - } else { - fileReader = c.Request.Body - } + fileReader := bytes.NewReader(fileBytes) client := getClient(c) ctx := context.Background() @@ -266,11 +260,6 @@ func PostObject(c *gin.Context) { // Upload if _, err := io.Copy(wc, fileReader); err != nil { - var maxBytesErr *http.MaxBytesError - if errors.As(err, &maxBytesErr) { - respond(c, http.StatusRequestEntityTooLarge, "error", fmt.Sprintf("File too large. Maximum allowed size is %d bytes (%dMB)", maxUploadSize, maxUploadSize/(1024*1024))) - return - } respondWithInternalError(c, err) return } @@ -364,10 +353,28 @@ func ObjectSignedURL(c *gin.Context) { respondWithInternalError(c, err) return } + + headers := append([]string{}, body.Headers...) + // Upload size limits for signed URL uploads. + if strings.EqualFold(body.Method, http.MethodPut) || strings.EqualFold(body.Method, http.MethodPost) { + maxUploadSize := configs.GetEnvMaxUploadSize() + hasContentLengthRange := false + for _, header := range headers { + if strings.HasPrefix(strings.ToLower(header), "x-goog-content-length-range:") { + hasContentLengthRange = true + break + } + } + + if !hasContentLengthRange { + headers = append(headers, fmt.Sprintf("x-goog-content-length-range:0,%d", maxUploadSize)) + } + } + opts := &storage.SignedURLOptions{ Scheme: storage.SigningSchemeV4, Method: body.Method, - Headers: body.Headers, + Headers: headers, Expires: expirationTime, } diff --git a/api/routes/storage.go b/api/routes/storage.go index f5504058..2bfcb0fa 100644 --- a/api/routes/storage.go +++ b/api/routes/storage.go @@ -30,6 +30,7 @@ func initStorageClient() *storage.Client { var err error _, exist := os.LookupEnv("USE_CLOUD_CREDS") + // If USE_CLOUD_CREDS env var set, assume we're running on cloud and don't need to set creds if exist { c, err = storage.NewClient(ctx) } else { From 5725e164b5fad519ec559e3218a4c6a4074e66d2 Mon Sep 17 00:00:00 2001 From: Justin Date: Mon, 20 Apr 2026 22:51:33 -0500 Subject: [PATCH 48/61] Remove redundant gin context variables in email --- api/controllers/email.go | 68 +++++----------------------------------- api/routes/email.go | 33 ++++++++----------- 2 files changed, 21 insertions(+), 80 deletions(-) diff --git a/api/controllers/email.go b/api/controllers/email.go index 9c87baef..a4670625 100644 --- a/api/controllers/email.go +++ b/api/controllers/email.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "net/http" + "os" "github.com/UTDNebula/nebula-api/api/schema" "github.com/gin-gonic/gin" @@ -13,60 +14,6 @@ import ( taskspb "cloud.google.com/go/cloudtasks/apiv2/cloudtaskspb" ) -// Get email client from routes -func getEmailClient(c *gin.Context) *mail.Client { - val, exists := c.Get("emailClient") - if !exists { - panic("email client not set in context") - } - return val.(*mail.Client) -} - -// Get email username from routes -func getEmailUsername(c *gin.Context) string { - val, exists := c.Get("emailUsername") - if !exists { - panic("email username not set in context") - } - return val.(string) -} - -// Get cloud tasks client from routes -func getTasksClient(c *gin.Context) *cloudtasks.Client { - val, exists := c.Get("tasksClient") - if !exists { - panic("tasks client not set in context") - } - return val.(*cloudtasks.Client) -} - -// Get queue path from routes -func getQueuePath(c *gin.Context) string { - val, exists := c.Get("queuePath") - if !exists { - panic("queue path not set in context") - } - return val.(string) -} - -// Get queue url from routes -func getQueueUrl(c *gin.Context) string { - val, exists := c.Get("queueUrl") - if !exists { - panic("queue url not set in context") - } - return val.(string) -} - -// Get email send key from routes -func getEmailSendKey(c *gin.Context) string { - val, exists := c.Get("emailSendKey") - if !exists { - panic("email send key not set in context") - } - return val.(string) -} - // @Id sendEmail // @Router /email/send [post] // @Tags Internal @@ -86,8 +33,8 @@ func SendEmail(c *gin.Context) { return } - client := getEmailClient(c) - smtpUsername := getEmailUsername(c) + client := c.MustGet("emailClient").(*mail.Client) + smtpUsername := os.Getenv("SMTP_USERNAME") m := mail.NewMsg() if err := m.FromFormat(req.From, smtpUsername); err != nil { @@ -144,9 +91,10 @@ func QueueEmail(c *gin.Context) { return } - client := getTasksClient(c) - queuePath := getQueuePath(c) - queueUrl := getQueueUrl(c) + client := c.MustGet("tasksClient").(*cloudtasks.Client) + + queuePath := os.Getenv("GCLOUD_EMAIL_QUEUE_PATH") + queueUrl := os.Getenv("GCLOUD_EMAIL_QUEUE_URL") // Build the Task payload. // https://docs.cloud.google.com/tasks/docs/creating-http-target-tasks @@ -158,7 +106,7 @@ func QueueEmail(c *gin.Context) { HttpMethod: taskspb.HttpMethod_POST, Url: queueUrl, Headers: map[string]string{ - "x-email-send-key": getEmailSendKey(c), + "x-email-send-key": os.Getenv("EMAIL_SEND_ROUTE_KEY"), // Must get from env bc queue only has x-email-queue-key header "x-api-key": c.GetHeader("x-api-key"), }, }, diff --git a/api/routes/email.go b/api/routes/email.go index 2831d5ee..1c4e2db0 100644 --- a/api/routes/email.go +++ b/api/routes/email.go @@ -16,18 +16,16 @@ import ( ) var emailClient *mail.Client -var smtpUsername string -var emailSendKey string var emailClientOnce sync.Once var tasksClient *cloudtasks.Client -var queuePath string -var queueUrl string var tasksClientOnce sync.Once -func initTasksClient() (*cloudtasks.Client, string, string) { +func initTasksClient() *cloudtasks.Client { // Singleton to prevent multiple clients tasksClientOnce.Do(func() { + + // Checking if env variables are set to know whether or not to skip the route qPath := os.Getenv("GCLOUD_EMAIL_QUEUE_PATH") qUrl := os.Getenv("GCLOUD_EMAIL_QUEUE_URL") @@ -43,15 +41,15 @@ func initTasksClient() (*cloudtasks.Client, string, string) { return } tasksClient = c - queuePath = qPath - queueUrl = qUrl }) - return tasksClient, queuePath, queueUrl + return tasksClient } -func initEmailClient() (*mail.Client, string, string) { +func initEmailClient() *mail.Client { // Singleton to prevent multiple clients emailClientOnce.Do(func() { + + // Checking if env variables are set to know whether or not to skip the route smtpHost := os.Getenv("SMTP_HOST") smtpUser := os.Getenv("SMTP_USERNAME") smtpPass := os.Getenv("SMTP_PASSWORD") @@ -74,16 +72,15 @@ func initEmailClient() (*mail.Client, string, string) { return } emailClient = c - smtpUsername = smtpUser }) - return emailClient, smtpUsername, emailSendKey + return emailClient } func EmailRoute(router *gin.Engine) { - client, username, emailSendKey := initEmailClient() - tClient, qPath, qUrl := initTasksClient() + eClient := initEmailClient() + tClient := initTasksClient() - if client == nil { + if eClient == nil { log.Println("SMTP client not initialized") } @@ -91,7 +88,7 @@ func EmailRoute(router *gin.Engine) { log.Println("Cloud Tasks client not initialized") } - if client == nil || tClient == nil { + if eClient == nil || tClient == nil { log.Println("skipping email routes") return } @@ -114,12 +111,8 @@ func EmailRoute(router *gin.Engine) { // Pass to next layer emailGroup.Use(func(c *gin.Context) { - c.Set("emailClient", client) - c.Set("emailUsername", username) - c.Set("emailSendKey", emailSendKey) + c.Set("emailClient", eClient) c.Set("tasksClient", tClient) - c.Set("queuePath", qPath) - c.Set("queueUrl", qUrl) c.Next() }) From 659e5c63967e9b50d5134e5930cc32bffbb8abd9 Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 22 Apr 2026 22:25:51 -0500 Subject: [PATCH 49/61] Add multi email recipients --- api/controllers/email.go | 76 +++++++++++++++++++++++----------------- api/schema/objects.go | 2 +- 2 files changed, 45 insertions(+), 33 deletions(-) diff --git a/api/controllers/email.go b/api/controllers/email.go index a4670625..1db93822 100644 --- a/api/controllers/email.go +++ b/api/controllers/email.go @@ -3,6 +3,7 @@ package controllers import ( "bytes" "encoding/json" + "fmt" "net/http" "os" @@ -42,7 +43,7 @@ func SendEmail(c *gin.Context) { return } - if err := m.To(req.To); err != nil { + if err := m.To(req.To[0]); err != nil { respond(c, http.StatusBadRequest, "invalid to address", err.Error()) return } @@ -77,7 +78,7 @@ func SendEmail(c *gin.Context) { // @Success 200 {object} schema.APIResponse[schema.EmailRequest] "Email Request Body with Queued Task Name" // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 400 {object} schema.APIResponse[string] "A string describing the error" -func QueueEmail(c *gin.Context) { +func QueueEmail(c *gin.Context) { // TODO: Update Swaggo! // Request must be able to bind to email request var emailReq schema.EmailRequest if err := c.ShouldBindJSON(&emailReq); err != nil { @@ -85,43 +86,54 @@ func QueueEmail(c *gin.Context) { return } - body, err := json.Marshal(emailReq) - if err != nil { - respond(c, http.StatusInternalServerError, "failed to serialize email request", err.Error()) - return - } - client := c.MustGet("tasksClient").(*cloudtasks.Client) - queuePath := os.Getenv("GCLOUD_EMAIL_QUEUE_PATH") queueUrl := os.Getenv("GCLOUD_EMAIL_QUEUE_URL") - // Build the Task payload. - // https://docs.cloud.google.com/tasks/docs/creating-http-target-tasks - taskReq := &taskspb.CreateTaskRequest{ - Parent: queuePath, - Task: &taskspb.Task{ - MessageType: &taskspb.Task_HttpRequest{ - HttpRequest: &taskspb.HttpRequest{ - HttpMethod: taskspb.HttpMethod_POST, - Url: queueUrl, - Headers: map[string]string{ - "x-email-send-key": os.Getenv("EMAIL_SEND_ROUTE_KEY"), // Must get from env bc queue only has x-email-queue-key header - "x-api-key": c.GetHeader("x-api-key"), + baseEmailReq := emailReq + baseEmailReq.To = []string{} + + queuedTasks := []string{} + + numOfRecipients := len(emailReq.To) + for i, to := range emailReq.To { + baseEmailReq.To = []string{to} + + body, err := json.Marshal(baseEmailReq) + if err != nil { + respond(c, http.StatusInternalServerError, fmt.Sprintf("failed to serialize email request for recipient %s %d/%d", to, i, numOfRecipients), err.Error()) + return + } + + // Build the Task payload. + // // https://docs.cloud.google.com/tasks/docs/creating-http-target-tasks + taskReq := &taskspb.CreateTaskRequest{ + Parent: queuePath, + Task: &taskspb.Task{ + MessageType: &taskspb.Task_HttpRequest{ + HttpRequest: &taskspb.HttpRequest{ + HttpMethod: taskspb.HttpMethod_POST, + Url: queueUrl, + Headers: map[string]string{ + "x-email-send-key": os.Getenv("EMAIL_SEND_ROUTE_KEY"), // Must get from env bc queue only has x-email-queue-key header + "x-api-key": c.GetHeader("x-api-key"), + }, }, }, }, - }, - } - - // Add a payload message if one is present. - taskReq.Task.GetHttpRequest().Body = []byte(body) - - task, err := client.CreateTask(c.Request.Context(), taskReq) - if err != nil { - respond(c, http.StatusInternalServerError, "failed to queue email", err.Error()) - return + } + + // Add a payload message if one is present. + taskReq.Task.GetHttpRequest().Body = []byte(body) + + task, err := client.CreateTask(c.Request.Context(), taskReq) + if err != nil { + respond(c, http.StatusInternalServerError, fmt.Sprintf("failed to queue email for recipient %s %d/%d", to, i, numOfRecipients), err.Error()) + return + } + + queuedTasks = append(queuedTasks, task.GetName()) } - respond(c, http.StatusOK, "success", task.GetName()) + respond(c, http.StatusOK, "success", queuedTasks) } diff --git a/api/schema/objects.go b/api/schema/objects.go index a94c0fd5..21f2191d 100644 --- a/api/schema/objects.go +++ b/api/schema/objects.go @@ -387,7 +387,7 @@ type EmailAttachment struct { type EmailRequest struct { From string `json:"from,omitempty"` - To string `json:"to" binding:"required,email"` + To []string `json:"to" binding:"required,dive,email"` Subject string `json:"subject" binding:"required"` Body string `json:"body" binding:"required"` Attachments []EmailAttachment `json:"attachments,omitempty"` From fbd987ee77e9b348d162da6115e2fb320076e700 Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 22 Apr 2026 22:37:16 -0500 Subject: [PATCH 50/61] Spread send email to recepients to allow email with many recipients --- api/controllers/email.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/api/controllers/email.go b/api/controllers/email.go index 1db93822..fff82711 100644 --- a/api/controllers/email.go +++ b/api/controllers/email.go @@ -43,8 +43,8 @@ func SendEmail(c *gin.Context) { return } - if err := m.To(req.To[0]); err != nil { - respond(c, http.StatusBadRequest, "invalid to address", err.Error()) + if err := m.To(req.To...); err != nil { + respond(c, http.StatusBadRequest, "invalid to address(es)", err.Error()) return } @@ -70,15 +70,15 @@ func SendEmail(c *gin.Context) { // @Id QueueEmail // @Router /email/queue [post] // @Tags Internal -// @Description "Queue an email to be sent via SMTP. This route is restricted to only Nebula Labs internal Projects." +// @Description "Queue an email to be sent via SMTP. Multi-recipient emails will be queued as separate emails to avoid bypassing queueing system. This route is restricted to only Nebula Labs internal Projects." // @Accept json -// @Produce json +// @Produce json // @Param request body schema.EmailRequest true "Email Request Body" // @Param x-email-queue-key header string true "The internal email queue key" -// @Success 200 {object} schema.APIResponse[schema.EmailRequest] "Email Request Body with Queued Task Name" +// @Success 200 {object} schema.APIResponse[[]string] "The list of queued task names" // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 400 {object} schema.APIResponse[string] "A string describing the error" -func QueueEmail(c *gin.Context) { // TODO: Update Swaggo! +func QueueEmail(c *gin.Context) { // Request must be able to bind to email request var emailReq schema.EmailRequest if err := c.ShouldBindJSON(&emailReq); err != nil { From a9ad5699f26727db46bf013f3a2817c98052bc07 Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 22 Apr 2026 22:39:48 -0500 Subject: [PATCH 51/61] Update swagger --- api/controllers/email.go | 14 +++++++------- api/docs/docs.go | 28 ++++++++++++++++++++++++---- api/docs/swagger.yaml | 24 +++++++++++++++++++----- 3 files changed, 50 insertions(+), 16 deletions(-) diff --git a/api/controllers/email.go b/api/controllers/email.go index fff82711..b5b5f51c 100644 --- a/api/controllers/email.go +++ b/api/controllers/email.go @@ -72,12 +72,12 @@ func SendEmail(c *gin.Context) { // @Tags Internal // @Description "Queue an email to be sent via SMTP. Multi-recipient emails will be queued as separate emails to avoid bypassing queueing system. This route is restricted to only Nebula Labs internal Projects." // @Accept json -// @Produce json -// @Param request body schema.EmailRequest true "Email Request Body" -// @Param x-email-queue-key header string true "The internal email queue key" -// @Success 200 {object} schema.APIResponse[[]string] "The list of queued task names" -// @Failure 500 {object} schema.APIResponse[string] "A string describing the error" -// @Failure 400 {object} schema.APIResponse[string] "A string describing the error" +// @Produce json +// @Param request body schema.EmailRequest true "Email Request Body" +// @Param x-email-queue-key header string true "The internal email queue key" +// @Success 200 {object} schema.APIResponse[[]string] "The list of queued task names" +// @Failure 500 {object} schema.APIResponse[string] "A string describing the error" +// @Failure 400 {object} schema.APIResponse[string] "A string describing the error" func QueueEmail(c *gin.Context) { // Request must be able to bind to email request var emailReq schema.EmailRequest @@ -131,7 +131,7 @@ func QueueEmail(c *gin.Context) { respond(c, http.StatusInternalServerError, fmt.Sprintf("failed to queue email for recipient %s %d/%d", to, i, numOfRecipients), err.Error()) return } - + queuedTasks = append(queuedTasks, task.GetName()) } diff --git a/api/docs/docs.go b/api/docs/docs.go index 2dfe37e9..10311e52 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -1039,7 +1039,7 @@ const docTemplate = `{ }, "/email/queue": { "post": { - "description": "\"Queue an email to be sent via SMTP. This route is restricted to only Nebula Labs internal Projects.\"", + "description": "\"Queue an email to be sent via SMTP. Multi-recipient emails will be queued as separate emails to avoid bypassing queueing system. This route is restricted to only Nebula Labs internal Projects.\"", "consumes": [ "application/json" ], @@ -1070,9 +1070,9 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "Email Request Body with Queued Task Name", + "description": "The list of queued task names", "schema": { - "$ref": "#/definitions/schema.APIResponse-schema_EmailRequest" + "$ref": "#/definitions/schema.APIResponse-array_string" } }, "400": { @@ -3442,6 +3442,23 @@ const docTemplate = `{ } } }, + "schema.APIResponse-array_string": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "string" + } + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + } + }, "schema.APIResponse-int": { "type": "object", "properties": { @@ -4133,7 +4150,10 @@ const docTemplate = `{ "type": "string" }, "to": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } } }, diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index c2e9fa0e..26d21c22 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -109,6 +109,17 @@ definitions: status: type: integer type: object + schema.APIResponse-array_string: + properties: + data: + items: + type: string + type: array + message: + type: string + status: + type: integer + type: object schema.APIResponse-int: properties: data: @@ -556,7 +567,9 @@ definitions: subject: type: string to: - type: string + items: + type: string + type: array required: - body - subject @@ -1666,8 +1679,9 @@ paths: post: consumes: - application/json - description: '"Queue an email to be sent via SMTP. This route is restricted - to only Nebula Labs internal Projects."' + description: '"Queue an email to be sent via SMTP. Multi-recipient emails will + be queued as separate emails to avoid bypassing queueing system. This route + is restricted to only Nebula Labs internal Projects."' operationId: QueueEmail parameters: - description: Email Request Body @@ -1685,9 +1699,9 @@ paths: - application/json responses: "200": - description: Email Request Body with Queued Task Name + description: The list of queued task names schema: - $ref: '#/definitions/schema.APIResponse-schema_EmailRequest' + $ref: '#/definitions/schema.APIResponse-array_string' "400": description: A string describing the error schema: From a1ae075adcfa7d4e3ea20e8c9af03eaddc583592 Mon Sep 17 00:00:00 2001 From: Kumud Arora Date: Thu, 23 Apr 2026 18:25:08 -0500 Subject: [PATCH 52/61] feat: add /combined/sections/trends endpoint --- api/controllers/trends.go | 23 ++++++++++++++++ api/docs/docs.go | 56 +++++++++++++++++++++++++++++++++++++++ api/docs/swagger.yaml | 40 ++++++++++++++++++++++++++++ api/routes/combined.go | 12 +++++++++ api/server.go | 1 + 5 files changed, 132 insertions(+) create mode 100644 api/routes/combined.go diff --git a/api/controllers/trends.go b/api/controllers/trends.go index 6857a8bb..ff47a4da 100644 --- a/api/controllers/trends.go +++ b/api/controllers/trends.go @@ -39,6 +39,21 @@ func TrendsProfessorSectionSearch(c *gin.Context) { trendsSectionSearch("Professor", c) } +// @Id trendsCombinedSectionSearch +// @Router /combined/sections/trends [get] +// @Tags Combined +// @Description "Returns sections matching both course and professor criteria from the combined trends collection. Specialized high-speed convenience endpoint for UTD Trends internal use; limited query flexibility." +// @Produce json +// @Param course_number query string true "The course's official number" +// @Param subject_prefix query string true "The course's subject prefix" +// @Param first_name query string true "The professor's first name" +// @Param last_name query string true "The professor's last name" +// @Success 200 {object} schema.APIResponse[[]schema.Section] "A list of Sections" +// @Failure 500 {object} schema.APIResponse[string] "A string describing the error" +func TrendsCombinedSectionSearch(c *gin.Context) { + trendsSectionSearch("Combined", c) +} + // trendsSectionSearch handles trends-based section routes for both course and professor query. // Reduce the repetitiveness of routes whose aggregation behaviors are identical. // This is subject to change as requests might be more complex. @@ -66,6 +81,14 @@ func trendsSectionSearch(flag string, c *gin.Context) { if err != nil { return } + case "Combined": + trendsCollection = configs.GetCollection("trends_combined_sections") + trendsQuery = bson.M{ + "course_prefix": c.Query("subject_prefix"), + "course_number": c.Query("course_number"), + "prof_first": c.Query("first_name"), + "prof_last": c.Query("last_name"), + } default: // This should never happen, but act as a fallback err = fmt.Errorf("invalid flag for trendsSectionSearch: %s", flag) diff --git a/api/docs/docs.go b/api/docs/docs.go index 4ec27356..9f845443 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -317,6 +317,62 @@ const docTemplate = `{ } } }, + "/combined/sections/trends": { + "get": { + "description": "\"Returns sections matching both course and professor criteria from the combined trends collection. Specialized high-speed convenience endpoint for UTD Trends internal use; limited query flexibility.\"", + "produces": [ + "application/json" + ], + "tags": [ + "Combined" + ], + "operationId": "trendsCombinedSectionSearch", + "parameters": [ + { + "type": "string", + "description": "The course's official number", + "name": "course_number", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "The course's subject prefix", + "name": "subject_prefix", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "The professor's first name", + "name": "first_name", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "The professor's last name", + "name": "last_name", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "A list of Sections", + "schema": { + "$ref": "#/definitions/schema.APIResponse-array_schema_Section" + } + }, + "500": { + "description": "A string describing the error", + "schema": { + "$ref": "#/definitions/schema.APIResponse-string" + } + } + } + } + }, "/course": { "get": { "description": "\"Returns paginated list of courses matching the query's string-typed key-value pairs. See offset for more details on pagination.\"", diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index c562d228..df2fdbad 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -1039,6 +1039,46 @@ paths: $ref: '#/definitions/schema.APIResponse-string' tags: - Events + /combined/sections/trends: + get: + description: '"Returns sections matching both course and professor criteria + from the combined trends collection. Specialized high-speed convenience endpoint + for UTD Trends internal use; limited query flexibility."' + operationId: trendsCombinedSectionSearch + parameters: + - description: The course's official number + in: query + name: course_number + required: true + type: string + - description: The course's subject prefix + in: query + name: subject_prefix + required: true + type: string + - description: The professor's first name + in: query + name: first_name + required: true + type: string + - description: The professor's last name + in: query + name: last_name + required: true + type: string + produces: + - application/json + responses: + "200": + description: A list of Sections + schema: + $ref: '#/definitions/schema.APIResponse-array_schema_Section' + "500": + description: A string describing the error + schema: + $ref: '#/definitions/schema.APIResponse-string' + tags: + - Combined /course: get: description: '"Returns paginated list of courses matching the query''s string-typed diff --git a/api/routes/combined.go b/api/routes/combined.go new file mode 100644 index 00000000..710c7834 --- /dev/null +++ b/api/routes/combined.go @@ -0,0 +1,12 @@ +package routes + +import ( + "github.com/UTDNebula/nebula-api/api/controllers" + "github.com/gin-gonic/gin" +) + +func CombinedRoute(router *gin.Engine) { + combinedGroup := router.Group("/combined") + combinedGroup.OPTIONS("", controllers.Preflight) + combinedGroup.GET("/sections/trends", controllers.TrendsCombinedSectionSearch) +} diff --git a/api/server.go b/api/server.go index f449a92a..0af86df1 100644 --- a/api/server.go +++ b/api/server.go @@ -76,6 +76,7 @@ func main() { routes.CourseRoute(router) routes.SectionRoute(router) routes.ProfessorRoute(router) + routes.CombinedRoute(router) routes.GradesRoute(router) routes.AutocompleteRoute(router) routes.StorageRoute(router) From 15c372bda637bbb55fdcff0a361c8cc721fe209e Mon Sep 17 00:00:00 2001 From: Kumud Arora Date: Thu, 23 Apr 2026 18:39:51 -0500 Subject: [PATCH 53/61] feat: add combined professor + course trends endpoint --- api/controllers/trends.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/api/controllers/trends.go b/api/controllers/trends.go index ff47a4da..313fc6fc 100644 --- a/api/controllers/trends.go +++ b/api/controllers/trends.go @@ -82,12 +82,13 @@ func trendsSectionSearch(flag string, c *gin.Context) { return } case "Combined": - trendsCollection = configs.GetCollection("trends_combined_sections") + trendsCollection = configs.GetCollection("trends_course_and_prof_sections") trendsQuery = bson.M{ - "course_prefix": c.Query("subject_prefix"), - "course_number": c.Query("course_number"), - "prof_first": c.Query("first_name"), - "prof_last": c.Query("last_name"), + "_id": bson.M{ + "course": c.Query("subject_prefix") + c.Query("course_number"), + "prof_first": c.Query("first_name"), + "prof_last": c.Query("last_name"), + }, } default: // This should never happen, but act as a fallback From 3a4ac49ec51c965f7383df20626472ea269ccb22 Mon Sep 17 00:00:00 2001 From: Justin Date: Thu, 23 Apr 2026 20:39:43 -0500 Subject: [PATCH 54/61] Add timeout for Queue Email --- api/controllers/email.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/api/controllers/email.go b/api/controllers/email.go index b5b5f51c..6cbb4939 100644 --- a/api/controllers/email.go +++ b/api/controllers/email.go @@ -2,10 +2,12 @@ package controllers import ( "bytes" + "context" "encoding/json" "fmt" "net/http" "os" + "time" "github.com/UTDNebula/nebula-api/api/schema" "github.com/gin-gonic/gin" @@ -79,6 +81,9 @@ func SendEmail(c *gin.Context) { // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 400 {object} schema.APIResponse[string] "A string describing the error" func QueueEmail(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + // Request must be able to bind to email request var emailReq schema.EmailRequest if err := c.ShouldBindJSON(&emailReq); err != nil { @@ -126,7 +131,7 @@ func QueueEmail(c *gin.Context) { // Add a payload message if one is present. taskReq.Task.GetHttpRequest().Body = []byte(body) - task, err := client.CreateTask(c.Request.Context(), taskReq) + task, err := client.CreateTask(ctx, taskReq) if err != nil { respond(c, http.StatusInternalServerError, fmt.Sprintf("failed to queue email for recipient %s %d/%d", to, i, numOfRecipients), err.Error()) return From 0cac14e044dd274a63ca4aad09593d93b3420111 Mon Sep 17 00:00:00 2001 From: Justin Date: Thu, 23 Apr 2026 20:41:38 -0500 Subject: [PATCH 55/61] Add timeout to Send Email --- api/controllers/email.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/api/controllers/email.go b/api/controllers/email.go index 6cbb4939..c225e932 100644 --- a/api/controllers/email.go +++ b/api/controllers/email.go @@ -29,6 +29,9 @@ import ( // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 400 {object} schema.APIResponse[string] "A string describing the error" func SendEmail(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + var req schema.EmailRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -61,7 +64,7 @@ func SendEmail(c *gin.Context) { m.EmbedReader(emb.Name, bytes.NewReader(emb.Data), mail.WithFileContentID(emb.Name)) } - if err := client.DialAndSend(m); err != nil { + if err := client.DialAndSendWithContext(ctx, m); err != nil { respond(c, http.StatusInternalServerError, "failed to send email", err.Error()) return } From eda4d09ac19f4fcf8aba2a8e1722ccaad5123afa Mon Sep 17 00:00:00 2001 From: ruba Date: Mon, 27 Apr 2026 10:59:59 -0500 Subject: [PATCH 56/61] feat: add Mazevo event routes for building- and room-specific querying --- api/controllers/mazevo.go | 104 +++++++++++++++++++++++++++++++++++++ api/controllers/section.go | 2 +- api/docs/docs.go | 103 ++++++++++++++++++++++++++++++++++++ api/docs/swagger.yaml | 73 ++++++++++++++++++++++++++ api/routes/mazevo.go | 2 + api/schema/objects.go | 2 +- 6 files changed, 284 insertions(+), 2 deletions(-) diff --git a/api/controllers/mazevo.go b/api/controllers/mazevo.go index 5372c172..0c97527a 100644 --- a/api/controllers/mazevo.go +++ b/api/controllers/mazevo.go @@ -47,3 +47,107 @@ func MazevoEvents(c *gin.Context) { respond(c, http.StatusOK, "success", mazevoEvents) } + +// @Id mazevoEventsByBuilding +// @Router /mazevo/{date}/{building} [get] +// @Tags Events +// @Description "Returns all sections with MazevoEvent meetings on the specified date in the specified building" +// @Produce json +// @Param date path string true "date (ISO format) to retrieve mazevo events" +// @Param building path string true "building abbreviation of the event location" +// @Success 200 {object} schema.APIResponse[schema.MultiBuildingEvents[schema.MazevoEvent]] "All MazevoEvents sections with meetings on the specified date in the specified building" +// @Failure 500 {object} schema.APIResponse[string] "A string describing the error" +// @Failure 404 {object} schema.APIResponse[string] "A string describing the error" +func MazevoEventsByBuilding(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + + date := c.Param("date") + building := c.Param("building") + + var mazevoEvents schema.MultiBuildingEvents[schema.MazevoEvent] + var mazevoEventsByBuilding schema.SingleBuildingEvents[schema.MazevoEvent] + + // find and parse matching date + err := mazevoCollection.FindOne(ctx, bson.M{"date": date}).Decode(&mazevoEvents) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + mazevoEvents.Date = date + mazevoEvents.Buildings = []schema.SingleBuildingEvents[schema.MazevoEvent]{} + } else { + respondWithInternalError(c, err) + return + } + } + + // filter by the specified building + for _, b := range mazevoEvents.Buildings { + if b.Building == building { + mazevoEventsByBuilding = b + break + } + } + + // if no building found return an error + if mazevoEventsByBuilding.Building == "" { + respond(c, http.StatusNotFound, "error", "No events found for the specified building") + return + } + + respond(c, http.StatusOK, "success", mazevoEventsByBuilding) +} + +// @Id mazevoEventsByRoom +// @Router /mazevo/{date}/{building}/{room} [get] +// @Tags Events +// @Description "Returns all sections with MazevoEvent meetings on the specified date in the specified building and room" +// @Produce json +// @Param date path string true "date (ISO format) to retrieve mazevo events" +// @Param building path string true "building abbreviation of the event location" +// @Param room path string true "room number" +// @Success 200 {object} schema.APIResponse[schema.MultiBuildingEvents[schema.MazevoEvent]] "All MazevoEvents sections with meetings on the specified date in the specified building" +// @Failure 500 {object} schema.APIResponse[string] "A string describing the error" +// @Failure 404 {object} schema.APIResponse[string] +func MazevoEventsByRoom(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + + date := c.Param("date") + building := c.Param("building") + room := c.Param("room") + + var mazevoEvents schema.MultiBuildingEvents[schema.MazevoEvent] + var mazevoEventsByRoom schema.RoomEvents[schema.MazevoEvent] + + // find and parse matching date + err := mazevoCollection.FindOne(ctx, bson.M{"date": date}).Decode(&mazevoEvents) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + mazevoEvents.Date = date + mazevoEvents.Buildings = []schema.SingleBuildingEvents[schema.MazevoEvent]{} + } else { + respondWithInternalError(c, err) + return + } + } + + // filter for the specified building and room + for _, b := range mazevoEvents.Buildings { + if b.Building == building { + for _, r := range b.Rooms { + if r.Room == room { + mazevoEventsByRoom = r + break + } + } + break + } + } + + if mazevoEventsByRoom.Room == "" { + respond(c, http.StatusNotFound, "error", "No events found for that specific building and room") + return + } + + respond(c, http.StatusOK, "success", mazevoEventsByRoom) +} diff --git a/api/controllers/section.go b/api/controllers/section.go index 58fcbd2d..c89323c3 100644 --- a/api/controllers/section.go +++ b/api/controllers/section.go @@ -355,4 +355,4 @@ func buildSectionPipeline(endpoint string, sectionQuery bson.M, paginate map[str } return append(append(append(baseStages, lookupStages...), replaceStages...), paginateStages...) -} \ No newline at end of file +} diff --git a/api/docs/docs.go b/api/docs/docs.go index 10311e52..caddc364 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -1563,6 +1563,109 @@ const docTemplate = `{ } } }, + "/mazevo/{date}/{building}": { + "get": { + "description": "\"Returns all sections with MazevoEvent meetings on the specified date in the specified building\"", + "produces": [ + "application/json" + ], + "tags": [ + "Events" + ], + "operationId": "mazevoEventsByBuilding", + "parameters": [ + { + "type": "string", + "description": "date (ISO format) to retrieve mazevo events", + "name": "date", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "building abbreviation of the event location", + "name": "building", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "All MazevoEvents sections with meetings on the specified date in the specified building", + "schema": { + "$ref": "#/definitions/schema.APIResponse-schema_MultiBuildingEvents-schema_MazevoEvent" + } + }, + "404": { + "description": "A string describing the error", + "schema": { + "$ref": "#/definitions/schema.APIResponse-string" + } + }, + "500": { + "description": "A string describing the error", + "schema": { + "$ref": "#/definitions/schema.APIResponse-string" + } + } + } + } + }, + "/mazevo/{date}/{building}/{room}": { + "get": { + "description": "\"Returns all sections with MazevoEvent meetings on the specified date in the specified building and room\"", + "produces": [ + "application/json" + ], + "tags": [ + "Events" + ], + "operationId": "mazevoEventsByRoom", + "parameters": [ + { + "type": "string", + "description": "date (ISO format) to retrieve mazevo events", + "name": "date", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "building abbreviation of the event location", + "name": "building", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "room number", + "name": "room", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "All MazevoEvents sections with meetings on the specified date in the specified building", + "schema": { + "$ref": "#/definitions/schema.APIResponse-schema_MultiBuildingEvents-schema_MazevoEvent" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/schema.APIResponse-string" + } + }, + "500": { + "description": "A string describing the error", + "schema": { + "$ref": "#/definitions/schema.APIResponse-string" + } + } + } + } + }, "/professor": { "get": { "description": "\"Returns paginated list of professors matching the query's string-typed key-value pairs. See offset for more details on pagination.\"", diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index 26d21c22..79586121 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -2034,6 +2034,79 @@ paths: $ref: '#/definitions/schema.APIResponse-string' tags: - Events + /mazevo/{date}/{building}: + get: + description: '"Returns all sections with MazevoEvent meetings on the specified + date in the specified building"' + operationId: mazevoEventsByBuilding + parameters: + - description: date (ISO format) to retrieve mazevo events + in: path + name: date + required: true + type: string + - description: building abbreviation of the event location + in: path + name: building + required: true + type: string + produces: + - application/json + responses: + "200": + description: All MazevoEvents sections with meetings on the specified date + in the specified building + schema: + $ref: '#/definitions/schema.APIResponse-schema_MultiBuildingEvents-schema_MazevoEvent' + "404": + description: A string describing the error + schema: + $ref: '#/definitions/schema.APIResponse-string' + "500": + description: A string describing the error + schema: + $ref: '#/definitions/schema.APIResponse-string' + tags: + - Events + /mazevo/{date}/{building}/{room}: + get: + description: '"Returns all sections with MazevoEvent meetings on the specified + date in the specified building and room"' + operationId: mazevoEventsByRoom + parameters: + - description: date (ISO format) to retrieve mazevo events + in: path + name: date + required: true + type: string + - description: building abbreviation of the event location + in: path + name: building + required: true + type: string + - description: room number + in: path + name: room + required: true + type: string + produces: + - application/json + responses: + "200": + description: All MazevoEvents sections with meetings on the specified date + in the specified building + schema: + $ref: '#/definitions/schema.APIResponse-schema_MultiBuildingEvents-schema_MazevoEvent' + "404": + description: Not Found + schema: + $ref: '#/definitions/schema.APIResponse-string' + "500": + description: A string describing the error + schema: + $ref: '#/definitions/schema.APIResponse-string' + tags: + - Events /professor: get: description: '"Returns paginated list of professors matching the query''s string-typed diff --git a/api/routes/mazevo.go b/api/routes/mazevo.go index ef401481..3fe61ac9 100644 --- a/api/routes/mazevo.go +++ b/api/routes/mazevo.go @@ -11,4 +11,6 @@ func MazevoRoute(router *gin.Engine) { mazevoGroup.OPTIONS("", controllers.Preflight) mazevoGroup.GET(":date", controllers.MazevoEvents) + mazevoGroup.GET(":date/:building", controllers.MazevoEventsByBuilding) + mazevoGroup.GET(":date/:building/:room", controllers.MazevoEventsByRoom) } diff --git a/api/schema/objects.go b/api/schema/objects.go index 21f2191d..8e79592a 100644 --- a/api/schema/objects.go +++ b/api/schema/objects.go @@ -328,7 +328,7 @@ type AcademicCalendar struct { MidtermsDue string `bson:"midterms_due" json:"midterms_due"` UniversityClosings [][]string `bson:"university_closings" json:"university_closings"` NoClasses [][]string `bson:"no_classes" json:"no_classes"` - URL string `bson:"url" json:"url"` + URL string `bson:"url" json:"url"` } type AcademicCalendarSession struct { Name string `bson:"name" json:"name"` From 5d8339586b9a4295e45b764dcf73af8ad3272dab Mon Sep 17 00:00:00 2001 From: Tyler Hill Date: Wed, 29 Apr 2026 19:19:44 -0500 Subject: [PATCH 57/61] Budget schema v1 --- api/schema/budgets.go | 55 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 api/schema/budgets.go diff --git a/api/schema/budgets.go b/api/schema/budgets.go new file mode 100644 index 00000000..b652c143 --- /dev/null +++ b/api/schema/budgets.go @@ -0,0 +1,55 @@ +package schema + +// Must also be updated in api-tools/parser/budgets.go + +// Table utils +type Table[T any] struct { + Name string `bson:"name" json:"name"` + Rows []Row[T] `bson:"rows" json:"rows"` + Total T `bson:"total" json:"total"` +} +type Table2[T any] struct { + Name string `bson:"name" json:"name"` + Rows []Table[T] `bson:"rows" json:"rows"` + Total T `bson:"total" json:"total"` +} +type Row[T any] struct { + Label string `bson:"label" json:"label"` + Value T `bson:"value" json:"value"` +} + +// Top level container for both the planned budget and the actual annual financial report +type Budget struct { + Id string `bson:"_id" json:"_id"` + OperatingBudget OperatingBudget `bson:"operating_budget" json:"operating_budget"` + AnnualFinancialReport AnnualFinancialReport `bson:"annual_financial_report" json:"annual_financial_report"` + Notes string `bson:"notes" json:"notes"` +} + +// Operating Budget Structs +type OperatingBudget struct { + OperatingRevenues *Table[float64] `bson:"operating_revenues" json:"operating_revenues"` + OperatingExpenses *Table[float64] `bson:"operating_expenses" json:"operating_expenses"` + BudgetedNonoperatingRevenues *Table[float64] `bson:"budgeted_nonoperating_revenues" json:"budgeted_nonoperating_revenues"` + BudgetedTuitionAndStudentFees *Table2[float64] `bson:"budgeted_tuition_and_student_fees" json:"budgeted_tuition_and_student_fees"` + BudgetBySchool *Table2[float64] `bson:"budget_by_school" json:"budget_by_school"` + AuxiliaryExpenses *Table2[AuxiliaryExpensesValues] `bson:"auxiliary_expenses" json:"auxiliary_expenses"` +} +type AuxiliaryExpensesValues struct { + EstimatedIncome float64 `bson:"estimated_income" json:"estimated_income"` + BudgetedExpenses float64 `bson:"budgeted_expenses" json:"budgeted_expenses"` + DebtService float64 `bson:"debt_service" json:"debt_service"` + Other float64 `bson:"other" json:"other"` + ExcessIncome float64 `bson:"excess_income" json:"excess_income"` + BeginningBalance float64 `bson:"beginning_balance" json:"beginning_balance"` + EndingBalance float64 `bson:"ending_balance" json:"ending_balance"` +} + +// Annual Financial Report (AFR) Structs +type AnnualFinancialReport struct { + OperatingRevenues *Table[float64] `bson:"operating_revenues" json:"operating_revenues"` + OperatingExpenses *Table[float64] `bson:"operating_expenses" json:"operating_expenses"` + BudgetedNonoperatingRevenues *Table[float64] `bson:"budgeted_nonoperating_revenues" json:"budgeted_nonoperating_revenues"` + BeginningNetPosition *float64 `bson:"beginning_net_position" json:"beginning_net_position"` + EndingNetPosition *float64 `bson:"ending_net_position" json:"ending_net_position"` +} From a6f69f32c26a7735feda1b7068772fb9f06149b4 Mon Sep 17 00:00:00 2001 From: Tyler Hill Date: Wed, 29 Apr 2026 19:20:00 -0500 Subject: [PATCH 58/61] Add comment to Academic Calendar schema --- api/schema/objects.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/schema/objects.go b/api/schema/objects.go index 21f2191d..791681f9 100644 --- a/api/schema/objects.go +++ b/api/schema/objects.go @@ -316,6 +316,7 @@ type ObjectSignedURLBody struct { } // Academic Calendar type +// Must also be updated in api-tools/parser/academicCalendars.go type AcademicCalendar struct { Id string `bson:"_id" json:"_id"` Timeline string `bson:"timeline" json:"timeline"` @@ -328,7 +329,7 @@ type AcademicCalendar struct { MidtermsDue string `bson:"midterms_due" json:"midterms_due"` UniversityClosings [][]string `bson:"university_closings" json:"university_closings"` NoClasses [][]string `bson:"no_classes" json:"no_classes"` - URL string `bson:"url" json:"url"` + URL string `bson:"url" json:"url"` } type AcademicCalendarSession struct { Name string `bson:"name" json:"name"` From 668fce9bfd552ea2afc660f7924a663f687589a2 Mon Sep 17 00:00:00 2001 From: Tyler Hill Date: Thu, 30 Apr 2026 02:45:23 -0500 Subject: [PATCH 59/61] Parse per school tables separately Co-authored-by: Copilot --- api/schema/budgets.go | 46 ++++++++++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/api/schema/budgets.go b/api/schema/budgets.go index b652c143..a677286e 100644 --- a/api/schema/budgets.go +++ b/api/schema/budgets.go @@ -20,36 +20,46 @@ type Row[T any] struct { // Top level container for both the planned budget and the actual annual financial report type Budget struct { - Id string `bson:"_id" json:"_id"` - OperatingBudget OperatingBudget `bson:"operating_budget" json:"operating_budget"` - AnnualFinancialReport AnnualFinancialReport `bson:"annual_financial_report" json:"annual_financial_report"` - Notes string `bson:"notes" json:"notes"` + Id string `bson:"_id" json:"_id"` + OperatingBudget *OperatingBudget `bson:"operating_budget" json:"operating_budget"` + AnnualFinancialReport *AnnualFinancialReport `bson:"annual_financial_report" json:"annual_financial_report"` + Notes string `bson:"notes" json:"notes"` } // Operating Budget Structs type OperatingBudget struct { - OperatingRevenues *Table[float64] `bson:"operating_revenues" json:"operating_revenues"` - OperatingExpenses *Table[float64] `bson:"operating_expenses" json:"operating_expenses"` - BudgetedNonoperatingRevenues *Table[float64] `bson:"budgeted_nonoperating_revenues" json:"budgeted_nonoperating_revenues"` - BudgetedTuitionAndStudentFees *Table2[float64] `bson:"budgeted_tuition_and_student_fees" json:"budgeted_tuition_and_student_fees"` - BudgetBySchool *Table2[float64] `bson:"budget_by_school" json:"budget_by_school"` - AuxiliaryExpenses *Table2[AuxiliaryExpensesValues] `bson:"auxiliary_expenses" json:"auxiliary_expenses"` + OperatingRevenues Table[float64] `bson:"operating_revenues" json:"operating_revenues"` + OperatingExpenses Table[float64] `bson:"operating_expenses" json:"operating_expenses"` + BudgetedNonoperatingRevenues Table[float64] `bson:"budgeted_nonoperating_revenues" json:"budgeted_nonoperating_revenues"` + SalariesDoeAndInstructionalAdmin Table[SalariesDoeAndInstructionalAdminValues] `bson:"salaries_doe_and_instructional_admin" json:"salaries_doe_and_instructional_admin"` + ServiceDepartmentsFunds Table2[FundsValues] `bson:"service_departments_funds" json:"service_departments_funds"` + DesignatedFunds Table2[FundsValues] `bson:"designated_funds" json:"designated_funds"` + BudgetedTuitionAndStudentFees Table2[float64] `bson:"budgeted_tuition_and_student_fees" json:"budgeted_tuition_and_student_fees"` + AuxiliaryExpenses Table2[AuxiliaryExpensesValues] `bson:"auxiliary_expenses" json:"auxiliary_expenses"` + RestrictedFunds Table2[FundsValues] `bson:"restricted_funds" json:"restricted_funds"` +} +type SalariesDoeAndInstructionalAdminValues struct { + Total float64 `bson:"total" json:"total"` + FacultySalaries float64 `bson:"faculty_salaries" json:"faculty_salaries"` + DepartmentalOperatingExpenses float64 `bson:"departmental_operating_expenses" json:"departmental_operating_expenses"` + InstructionalAdministration float64 `bson:"instructional_administration" json:"instructional_administration"` +} +type FundsValues struct { + EstimatedIncome float64 `bson:"estimated_income" json:"estimated_income"` + BudgetedExpenses float64 `bson:"budgeted_expenses" json:"budgeted_expenses"` } type AuxiliaryExpensesValues struct { EstimatedIncome float64 `bson:"estimated_income" json:"estimated_income"` BudgetedExpenses float64 `bson:"budgeted_expenses" json:"budgeted_expenses"` DebtService float64 `bson:"debt_service" json:"debt_service"` Other float64 `bson:"other" json:"other"` - ExcessIncome float64 `bson:"excess_income" json:"excess_income"` - BeginningBalance float64 `bson:"beginning_balance" json:"beginning_balance"` - EndingBalance float64 `bson:"ending_balance" json:"ending_balance"` } // Annual Financial Report (AFR) Structs type AnnualFinancialReport struct { - OperatingRevenues *Table[float64] `bson:"operating_revenues" json:"operating_revenues"` - OperatingExpenses *Table[float64] `bson:"operating_expenses" json:"operating_expenses"` - BudgetedNonoperatingRevenues *Table[float64] `bson:"budgeted_nonoperating_revenues" json:"budgeted_nonoperating_revenues"` - BeginningNetPosition *float64 `bson:"beginning_net_position" json:"beginning_net_position"` - EndingNetPosition *float64 `bson:"ending_net_position" json:"ending_net_position"` + OperatingRevenues Table[float64] `bson:"operating_revenues" json:"operating_revenues"` + OperatingExpenses Table[float64] `bson:"operating_expenses" json:"operating_expenses"` + NonoperatingRevenues Table[float64] `bson:"nonoperating_revenues" json:"nonoperating_revenues"` + BeginningNetPosition float64 `bson:"beginning_net_position" json:"beginning_net_position"` + EndingNetPosition float64 `bson:"ending_net_position" json:"ending_net_position"` } From 1531537ab49b35315daad54a6cfcb9d1b526816b Mon Sep 17 00:00:00 2001 From: mikehquan19 Date: Tue, 5 May 2026 23:30:01 -0500 Subject: [PATCH 60/61] Fix the swag and bump the version --- api/docs/docs.go | 94 ++++++++++++++++++++++++++----------------- api/docs/swagger.yaml | 71 ++++++++++++++++++-------------- api/server.go | 2 +- 3 files changed, 99 insertions(+), 68 deletions(-) diff --git a/api/docs/docs.go b/api/docs/docs.go index 13e86de2..ba940344 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -317,9 +317,6 @@ const docTemplate = `{ } } }, - "/combined/sections/trends": { - "get": { - "description": "\"Returns sections matching both course and professor criteria from the combined trends collection. Specialized high-speed convenience endpoint for UTD Trends internal use; limited query flexibility.\"", "/club/search": { "get": { "description": "\"Returns list of clubs matching the search string\"", @@ -327,36 +324,6 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Combined" - ], - "operationId": "trendsCombinedSectionSearch", - "parameters": [ - { - "type": "string", - "description": "The course's official number", - "name": "course_number", - "in": "query", - "required": true - }, - { - "type": "string", - "description": "The course's subject prefix", - "name": "subject_prefix", - "in": "query", - "required": true - }, - { - "type": "string", - "description": "The professor's first name", - "name": "first_name", - "in": "query", - "required": true - }, - { - "type": "string", - "description": "The professor's last name", - "name": "last_name", - "in": "query", "Clubs" ], "operationId": "clubSearch", @@ -412,9 +379,6 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "A list of Sections", - "schema": { - "$ref": "#/definitions/schema.APIResponse-array_schema_Section" "description": "A club", "schema": { "$ref": "#/definitions/schema.APIResponse-schema_Club" @@ -435,6 +399,62 @@ const docTemplate = `{ } } }, + "/combined/sections/trends": { + "get": { + "description": "\"Returns sections matching both course and professor criteria from the combined trends collection. Specialized high-speed convenience endpoint for UTD Trends internal use; limited query flexibility.\"", + "produces": [ + "application/json" + ], + "tags": [ + "Combined" + ], + "operationId": "trendsCombinedSectionSearch", + "parameters": [ + { + "type": "string", + "description": "The course's official number", + "name": "course_number", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "The course's subject prefix", + "name": "subject_prefix", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "The professor's first name", + "name": "first_name", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "The professor's last name", + "name": "last_name", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "A list of Sections", + "schema": { + "$ref": "#/definitions/schema.APIResponse-array_schema_Section" + } + }, + "500": { + "description": "A string describing the error", + "schema": { + "$ref": "#/definitions/schema.APIResponse-string" + } + } + } + } + }, "/course": { "get": { "description": "\"Returns paginated list of courses matching the query's string-typed key-value pairs. See offset for more details on pagination.\"", @@ -4945,7 +4965,7 @@ const docTemplate = `{ // SwaggerInfo holds exported Swagger Info so clients can modify it var SwaggerInfo = &swag.Spec{ - Version: "1.2.0", + Version: "1.3.0", Host: "api.utdnebula.com", BasePath: "", Schemes: []string{"https", "http"}, diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index 318b2374..ac516237 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -973,7 +973,7 @@ info: contact: {} description: The public Nebula Labs API for access to pertinent UT Dallas data title: nebula-api - version: 1.2.0 + version: 1.3.0 paths: /astra/{date}: get: @@ -1184,31 +1184,6 @@ paths: $ref: '#/definitions/schema.APIResponse-string' tags: - Events - /combined/sections/trends: - get: - description: '"Returns sections matching both course and professor criteria - from the combined trends collection. Specialized high-speed convenience endpoint - for UTD Trends internal use; limited query flexibility."' - operationId: trendsCombinedSectionSearch - parameters: - - description: The course's official number - in: query - name: course_number - required: true - type: string - - description: The course's subject prefix - in: query - name: subject_prefix - required: true - type: string - - description: The professor's first name - in: query - name: first_name - required: true - type: string - - description: The professor's last name - in: query - name: last_name /club/{id}: get: description: '"Returns the directory info for given club."' @@ -1250,9 +1225,6 @@ paths: - application/json responses: "200": - description: A list of Sections - schema: - $ref: '#/definitions/schema.APIResponse-array_schema_Section' description: List of matching clubs schema: $ref: '#/definitions/schema.APIResponse-array_schema_Club' @@ -1265,8 +1237,47 @@ paths: schema: $ref: '#/definitions/schema.APIResponse-string' tags: - - Combined - Clubs + /combined/sections/trends: + get: + description: '"Returns sections matching both course and professor criteria + from the combined trends collection. Specialized high-speed convenience endpoint + for UTD Trends internal use; limited query flexibility."' + operationId: trendsCombinedSectionSearch + parameters: + - description: The course's official number + in: query + name: course_number + required: true + type: string + - description: The course's subject prefix + in: query + name: subject_prefix + required: true + type: string + - description: The professor's first name + in: query + name: first_name + required: true + type: string + - description: The professor's last name + in: query + name: last_name + required: true + type: string + produces: + - application/json + responses: + "200": + description: A list of Sections + schema: + $ref: '#/definitions/schema.APIResponse-array_schema_Section' + "500": + description: A string describing the error + schema: + $ref: '#/definitions/schema.APIResponse-string' + tags: + - Combined /course: get: description: '"Returns paginated list of courses matching the query''s string-typed diff --git a/api/server.go b/api/server.go index d16f8d1a..7dfbb4d4 100644 --- a/api/server.go +++ b/api/server.go @@ -26,7 +26,7 @@ func swagger_controller_placeholder() {} // @title nebula-api // @description The public Nebula Labs API for access to pertinent UT Dallas data -// @version 1.2.0 +// @version 1.3.0 // @host api.utdnebula.com // @schemes https http // @x-google-backend {"address": "https://nebula-api-1062216541483.us-central1.run.app"} From 195be36effd83499aa5e38ba6d53aa7870eb1c89 Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 6 May 2026 22:52:04 -0500 Subject: [PATCH 61/61] move email context timeout to inside for loop --- api/controllers/email.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/controllers/email.go b/api/controllers/email.go index c225e932..74efedf8 100644 --- a/api/controllers/email.go +++ b/api/controllers/email.go @@ -84,9 +84,6 @@ func SendEmail(c *gin.Context) { // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 400 {object} schema.APIResponse[string] "A string describing the error" func QueueEmail(c *gin.Context) { - ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) - defer cancel() - // Request must be able to bind to email request var emailReq schema.EmailRequest if err := c.ShouldBindJSON(&emailReq); err != nil { @@ -105,6 +102,9 @@ func QueueEmail(c *gin.Context) { numOfRecipients := len(emailReq.To) for i, to := range emailReq.To { + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + baseEmailReq.To = []string{to} body, err := json.Marshal(baseEmailReq)