Skip to content

Commit

Permalink
Add courses aggregate endpoint to professor endpoint (#234)
Browse files Browse the repository at this point in the history
* Add courses aggregate endpoint to professor endpoint

* fix the comment

* Add the sections aggregate endpoints to the course endpoint

* Add pagination to course and section aggregate endpoints
  • Loading branch information
mikehquan19 authored Nov 21, 2024
1 parent 3aceb23 commit 85bdc3c
Show file tree
Hide file tree
Showing 5 changed files with 294 additions and 0 deletions.
22 changes: 22 additions & 0 deletions api/configs/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,25 @@ func GetOptionLimit(query *bson.M, c *gin.Context) (*options.FindOptions, error)

return options.Find().SetSkip(offset).SetLimit(limit), err
}

// TODO: Is there a chance we can combine this with GetOptionLimit to reduce repretiveness ?
// Returns pairs of the offset and limit for pagination stage for aggregate endpoints pipeline
// returns (offset, limit, err)
func GetAggregateLimit(query *bson.M, c *gin.Context) (int64, int64, error) {
delete(*query, "offset") // remove offset field (if present) in the query

// parses offset if included in the query
var limit int64 = GetEnvLimit()
var offset int64
var err error

if c.Query("offset") == "" {
offset = 0 // default value
} else {
offset, err = strconv.ParseInt(c.Query("offset"), 10, 64)
if err != nil {
return offset, limit, err // default value
}
}
return offset, limit, err
}
121 changes: 121 additions & 0 deletions api/controllers/course.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,124 @@ func CourseAll(c *gin.Context) {
// return result
c.JSON(http.StatusOK, responses.MultiCourseResponse{Status: http.StatusOK, Message: "success", Data: courses})
}

// @Id courseSectionSearch
// @Router /course/sections [get]
// @Description "Returns all the sections of all the courses matching the query's string-typed key-value pairs"
// @Produce json
// @Param course_number query string false "The course's official number"
// @Param subject_prefix query string false "The course's subject prefix"
// @Param title query string false "The course's title"
// @Param description query string false "The course's description"
// @Param school query string false "The course's school"
// @Param credit_hours query string false "The number of credit hours awarded by successful completion of the course"
// @Param class_level query string false "The level of education that this course course corresponds to"
// @Param activity_type query string false "The type of class this course corresponds to"
// @Param grading query string false "The grading status of this course"
// @Param internal_course_number query string false "The internal (university) number used to reference this course"
// @Param lecture_contact_hours query string false "The weekly contact hours in lecture for a course"
// @Param offering_frequency query string false "The frequency of offering a course"
// @Success 200 {array} schema.Section "A list of sections"
func CourseSectionSearch() gin.HandlerFunc {
return func(c *gin.Context) {
courseSection("Search", c)
}
}

// @Id courseSectionById
// @Router /course/{id}/sections [get]
// @Description "Returns the all of the sections of the course with given ID"
// @Produce json
// @Param id path string true "ID of the course to get"
// @Success 200 {array} schema.Section "A list of sections"
func CourseSectionById() gin.HandlerFunc {
return func(c *gin.Context) {
courseSection("ById", c)
}
}

// get the sections of the courses, filters depending on the flag
func courseSection(flag string, c *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

var courseSections []schema.Section // the list of sections of the filtered courses
var courseQuery bson.M // query of the courses (or the single course)
var err error // error

// determine the course query
if flag == "Search" { // filter courses based on the query parameters
// build the key-value pair of query parameters
courseQuery, err = schema.FilterQuery[schema.Course](c)
if err != nil {
// return the validation error if there's anything wrong
c.JSON(http.StatusBadRequest, responses.ErrorResponse{Status: http.StatusBadRequest, Message: "schema validation error", Data: err.Error()})
return
}
} else if flag == "ById" { // filter the single course based on it's Id
// convert the id param with the ObjectID
courseId := c.Param("id")
courseObjId, convertIdErr := primitive.ObjectIDFromHex(courseId)
if convertIdErr != nil {
// return the id conversion error if there's error
log.WriteError(convertIdErr)
c.JSON(http.StatusBadRequest, responses.ErrorResponse{Status: http.StatusBadRequest, Message: "error with id", Data: convertIdErr.Error()})
return
}
courseQuery = bson.M{"_id": courseObjId}
} else {
// otherwise, something that messed up the server
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{Status: http.StatusInternalServerError, Message: "internal error", Data: "broken endpoint"})
return
}

// determine the offset and limit for pagination stage
// and delete "offset" field in professorQuery
offset, limit, err := configs.GetAggregateLimit(&courseQuery, c)
if err != nil {
log.WriteErrorWithMsg(err, log.OffsetNotTypeInteger)
c.JSON(http.StatusConflict, responses.ErrorResponse{Status: http.StatusConflict, Message: "Error offset is not type integer", Data: err.Error()})
return
}

// pipeline to query the sections from the filtered courses
courseSectionPipeline := mongo.Pipeline{
// filter the courses
bson.D{{Key: "$match", Value: courseQuery}},

// paginate the courses before pulling the sections from thoses courses
bson.D{{Key: "$skip", Value: offset}}, // skip to the specified offset
bson.D{{Key: "$limit", Value: limit}}, // limit to the specified number of courses

// lookup the sections of the courses
bson.D{{Key: "$lookup", Value: bson.D{
{Key: "from", Value: "sections"},
{Key: "localField", Value: "sections"},
{Key: "foreignField", Value: "_id"},
{Key: "as", Value: "sections"},
}}},

// unwind the sections of the courses
bson.D{{Key: "$unwind", Value: bson.D{
{Key: "path", Value: "$sections"},
{Key: "preserveNullAndEmptyArrays", Value: false}, // avoid course documents that can't be replaced
}}},

// replace the courses with sections
bson.D{{Key: "$replaceWith", Value: "$sections"}},
}

// perform aggregation on the pipeline
cursor, err := courseCollection.Aggregate(ctx, courseSectionPipeline)
if err != nil {
// return error for any aggregation problem
log.WriteError(err)
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{Status: http.StatusInternalServerError, Message: "aggregation error", Data: err.Error()})
return
}
// parse the array of sections of the course
if err = cursor.All(ctx, &courseSections); err != nil {
panic(err)
}
c.JSON(http.StatusOK, responses.MultiSectionResponse{Status: http.StatusOK, Message: "success", Data: courseSections})
}
143 changes: 143 additions & 0 deletions api/controllers/professor.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ func ProfessorSearch(c *gin.Context) {
}

// return result
print(len(professors))
c.JSON(http.StatusOK, responses.MultiProfessorResponse{Status: http.StatusOK, Message: "success", Data: professors})
}

Expand Down Expand Up @@ -143,3 +144,145 @@ func ProfessorAll(c *gin.Context) {
// return result
c.JSON(http.StatusOK, responses.MultiProfessorResponse{Status: http.StatusOK, Message: "success", Data: professors})
}

// @Id professorCourseSearch
// @Router /professor/courses [get]
// @Description "Returns all of the courses of all the professors matching the query's string-typed key-value pairs"
// @Produce json
// @Param first_name query string false "The professor's first name"
// @Param last_name query string false "The professor's last name"
// @Param titles query string false "One of the professor's title"
// @Param email query string false "The professor's email address"
// @Param phone_number query string false "The professor's phone number"
// @Param office.building query string false "The building of the location of the professor's office"
// @Param office.room query string false "The room of the location of the professor's office"
// @Param office.map_uri query string false "A hyperlink to the UTD room locator of the professor's office"
// @Param profile_uri query string false "A hyperlink pointing to the professor's official university profile"
// @Param image_uri query string false "A link to the image used for the professor on the professor's official university profile"
// @Param office_hours.start_date query string false "The start date of one of the office hours meetings of the professor"
// @Param office_hours.end_date query string false "The end date of one of the office hours meetings of the professor"
// @Param office_hours.meeting_days query string false "One of the days that one of the office hours meetings of the professor"
// @Param office_hours.start_time query string false "The time one of the office hours meetings of the professor starts"
// @Param office_hours.end_time query string false "The time one of the office hours meetings of the professor ends"
// @Param office_hours.modality query string false "The modality of one of the office hours meetings of the professor"
// @Param office_hours.location.building query string false "The building of one of the office hours meetings of the professor"
// @Param office_hours.location.room query string false "The room of one of the office hours meetings of the professor"
// @Param office_hours.location.map_uri query string false "A hyperlink to the UTD room locator of one of the office hours meetings of the professor"
// @Param sections query string false "The _id of one of the sections the professor teaches"
// @Success 200 {array} schema.Course "A list of Courses"
func ProfessorCourseSearch() gin.HandlerFunc {
// Wrapper of professorCourse() with flag of Search
return func(c *gin.Context) {
professorCourse("Search", c)
}
}

// @Id professorCourseById
// @Router /professor/{id}/courses [get]
// @Description "Returns all the courses taught by the professor with given ID"
// @Produce json
// @Param id path string true "ID of the professor to get"
// @Success 200 {array} schema.Course "A list of courses"
func ProfessorCourseById() gin.HandlerFunc {
// Essentially wrapper of professorCourse() with flag of ById
return func(c *gin.Context) {
professorCourse("ById", c)
}
}

// Get all of the courses of the professors depending on the type of flag
func professorCourse(flag string, c *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)

var professorCourses []schema.Course // array of courses of the professors (or single professor with Id)
var professorQuery bson.M // query filter the professor
var err error

defer cancel()

// determine the professor's query
if flag == "Search" { // if the flag is Search, filter professors based on query parameters
// build the key-value pairs of query parameters
professorQuery, err = schema.FilterQuery[schema.Professor](c)
if err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{Status: http.StatusBadRequest, Message: "schema validation error", Data: err.Error()})
return
}
} else if flag == "ById" { // if the flag is ById, filter that single professor based on their _id
// parse the ObjectId
professorId := c.Param("id")
professorObjId, err := primitive.ObjectIDFromHex(professorId)
if err != nil {
log.WriteError(err)
c.JSON(http.StatusBadRequest, responses.ErrorResponse{Status: http.StatusBadRequest, Message: "error", Data: err.Error()})
return
}
professorQuery = bson.M{"_id": professorObjId}
} else {
// something wrong that messed up the server
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{Status: http.StatusInternalServerError, Message: "error", Data: "Endpoint broken"})
return
}

// determine the offset and limit for pagination stage
// and delete "offset" field in professorQuery
offset, limit, err := configs.GetAggregateLimit(&professorQuery, c)
if err != nil {
log.WriteErrorWithMsg(err, log.OffsetNotTypeInteger)
c.JSON(http.StatusConflict, responses.ErrorResponse{Status: http.StatusConflict, Message: "Error offset is not type integer", Data: err.Error()})
return
}

// Pipeline to query the courses from the filtered professors (or a single professor)
professorCoursePipeline := mongo.Pipeline{
// filter the professors
bson.D{{Key: "$match", Value: professorQuery}},

// paginate the professors before pulling the courses from those professor
bson.D{{Key: "$skip", Value: offset}}, // skip to the specified offset
bson.D{{Key: "$limit", Value: limit}}, // limit to the specified number of professors

// lookup the array of sections from sections collection
bson.D{{Key: "$lookup", Value: bson.D{
{Key: "from", Value: "sections"},
{Key: "localField", Value: "sections"},
{Key: "foreignField", Value: "_id"},
{Key: "as", Value: "sections"},
}}},

// project the courses referenced by each section in the array
bson.D{{Key: "$project", Value: bson.D{{Key: "courses", Value: "$sections.course_reference"}}}},

// lookup the array of courses from coures collection
bson.D{{Key: "$lookup", Value: bson.D{
{Key: "from", Value: "courses"},
{Key: "localField", Value: "courses"},
{Key: "foreignField", Value: "_id"},
{Key: "as", Value: "courses"},
}}},

// unwind the courses
bson.D{{Key: "$unwind", Value: bson.D{
{Key: "path", Value: "$courses"},
{Key: "preserveNullAndEmptyArrays", Value: false}, // to avoid the professor documents that can't be replaced
}}},

// replace the combination of ids and courses with the courses entirely
bson.D{{Key: "$replaceWith", Value: "$courses"}},
}

// Perform aggreration on the pipeline
cursor, err := professorCollection.Aggregate(ctx, professorCoursePipeline)
if err != nil {
// return the error with there's something wrong with the aggregation
log.WriteError(err)
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{Status: http.StatusInternalServerError, Message: "error", Data: err.Error()})
return
}
// Parse the array of courses from these professors
if err = cursor.All(ctx, &professorCourses); err != nil {
log.WritePanic(err)
panic(err)
}
c.JSON(http.StatusOK, responses.MultiCourseResponse{Status: http.StatusOK, Message: "success", Data: professorCourses})
}
4 changes: 4 additions & 0 deletions api/routes/course.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,8 @@ func CourseRoute(router *gin.Engine) {
courseGroup.GET("", controllers.CourseSearch)
courseGroup.GET(":id", controllers.CourseById)
courseGroup.GET("all", controllers.CourseAll)

// Endpoint to get the list of sections of the queried course, courses
courseGroup.GET("/sections", controllers.CourseSectionSearch())
courseGroup.GET("/:id/sections", controllers.CourseSectionById())
}
4 changes: 4 additions & 0 deletions api/routes/professor.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,8 @@ func ProfessorRoute(router *gin.Engine) {
professorGroup.GET("", controllers.ProfessorSearch)
professorGroup.GET(":id", controllers.ProfessorById)
professorGroup.GET("all", controllers.ProfessorAll)

// Endpoints to get the courses of the professors
professorGroup.GET("courses", controllers.ProfessorCourseSearch())
professorGroup.GET(":id/courses", controllers.ProfessorCourseById())
}

0 comments on commit 85bdc3c

Please sign in to comment.