diff --git a/.env.template b/.env.template index 3e121d0..696ece2 100644 --- a/.env.template +++ b/.env.template @@ -23,4 +23,8 @@ AUTH0_AUDIENCE= # These two are different from the previous ones, from these ones we're getting the Management API JWT AUTH0_TEST_CLIENT_ID= -AUTH0_TEST_CLIENT_SECRET= \ No newline at end of file +AUTH0_TEST_CLIENT_SECRET= + +#Port for the code runner container +RUNNER_PORT= +RUNNER_URL= \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml index efb474e..c883281 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -273,7 +273,7 @@ linters: - nestif # reports deeply nested if statements - nilerr # finds the code that returns nil even if it checks that the error is not nil - nilnil # checks that there is no simultaneous return of nil error and an invalid value - - noctx # finds sending http request without context.Context +# - noctx # finds sending http request without context.Context - nolintlint # reports ill-formed or insufficient nolint directives - nonamedreturns # reports all named returns - nosprintfhostport # checks for misuse of Sprintf to construct a host with port in a URL diff --git a/config/routerConfig.go b/config/routerConfig.go index 714820a..e0a76c2 100644 --- a/config/routerConfig.go +++ b/config/routerConfig.go @@ -6,12 +6,20 @@ import ( "gorm.io/gorm" "net/http" "rpl-service/controllers/course" + "rpl-service/controllers/exercise" "rpl-service/models" "rpl-service/platform/middleware" ) func InitializeRoutes(router *gin.Engine, db *gorm.DB) { - for _, endpoint := range course.Endpoints { + initializeRoutes(router, db, course.Endpoints) + initializeRoutes(router, db, exercise.Endpoints) +} + +// Private methods + +func initializeRoutes(router *gin.Engine, db *gorm.DB, endpoints []models.Endpoint) { + for _, endpoint := range endpoints { mapToGinRoute(router, endpoint, db) } } diff --git a/controllers/course/courseController.go b/controllers/course/courseController.go index f80fca1..bce2e41 100644 --- a/controllers/course/courseController.go +++ b/controllers/course/courseController.go @@ -38,7 +38,7 @@ func Exists(w http.ResponseWriter, r *http.Request, db *gorm.DB) { } func Create(w http.ResponseWriter, r *http.Request, db *gorm.DB) { - // Should Create a new course + // Should create a new course var body models.Course err := json.NewDecoder(r.Body).Decode(&body) if err != nil { diff --git a/controllers/exercise/exerciseController.go b/controllers/exercise/exerciseController.go new file mode 100644 index 0000000..5cfb864 --- /dev/null +++ b/controllers/exercise/exerciseController.go @@ -0,0 +1,68 @@ +package exercise + +import ( + "encoding/json" + "github.com/google/uuid" + "gorm.io/gorm" + "io" + "net/http" + "rpl-service/models" + "rpl-service/services/exercises" +) + +/** +{ + "exerciseID": "...", + "code" : "..." +} +*/ + +func SolveExercise(w http.ResponseWriter, r *http.Request, db *gorm.DB) { + // With exerciseId, I should search for all its tests, run one by one, and then send the result of each one as JSONs + exerciseID, err := uuid.Parse(r.PathValue("exerciseId")) + if err != nil { + http.Error(w, "Invalid exercise ID format", http.StatusBadRequest) + return + } + + var exercise models.Exercise + db.Model(models.Exercise{}).Where(&exercise, "ID = ?", exerciseID) + + // TODO: make a more valid check, exercise.ID is currently a uint, don't know why + // TODO 2: we may need to remove the gorm.Model and manually declare each UUID as primary key + if exercise.Name == "" { + http.Error(w, "No exercise with that ID", http.StatusNotFound) + return + } + var response models.SolveExerciseResponse + body, readErr := io.ReadAll(r.Body) + if readErr != nil { + http.Error(w, "Invalid body format", http.StatusBadRequest) + return + } + if respErr := json.Unmarshal(body, &response); respErr != nil { + http.Error(w, "Invalid body format", http.StatusBadRequest) + return + } + results, exerciseError := exercises.SolveExercise(exerciseID, db, response.ExerciseCode) + if exerciseError != nil { + http.Error(w, "Error while executing tests", http.StatusInternalServerError) + return + } + byteResults, err := json.Marshal(results) //nolint:musttag // No need + if err != nil { + return + } + + _, writeErr := w.Write(byteResults) + if writeErr != nil { + return + } +} +func CreateExercise(_ http.ResponseWriter, _ *http.Request, _ *gorm.DB) { + // TODO +} + +func FindExercise(_ http.ResponseWriter, _ *http.Request, _ *gorm.DB) { + // TODO +} diff --git a/controllers/exercise/exerciseRouter.go b/controllers/exercise/exerciseRouter.go new file mode 100644 index 0000000..f0df5aa --- /dev/null +++ b/controllers/exercise/exerciseRouter.go @@ -0,0 +1,34 @@ +package exercise + +import ( + "rpl-service/models" +) + +const BaseURL = "/exercise" // May change + +var SolveExerciseEndpoint = models.Endpoint{ + Method: models.POST, + Path: BaseURL + "/{exerciseId}", + HandlerFunction: SolveExercise, + IsProtected: true, +} + +var CreateExerciseEndpoint = models.Endpoint{ + Method: models.POST, + Path: BaseURL, + HandlerFunction: CreateExercise, + IsProtected: true, +} + +var GetExerciseEndpoint = models.Endpoint{ + Method: models.GET, + Path: BaseURL + "/{exerciseId}", + HandlerFunction: FindExercise, + IsProtected: true, +} + +var Endpoints = []models.Endpoint{ + SolveExerciseEndpoint, + CreateExerciseEndpoint, + GetExerciseEndpoint, +} diff --git a/docker-compose.yml b/docker-compose.yml index 7f4095f..9543627 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,5 +23,13 @@ services: volumes: - db_data:/var/lib/postgresql/data + runner: + container_name: code-runner + image: "gchr.io/willytonkas/code-runner:latest" + depends_on: + - app + ports: + - ${RUNNER_PORT}:${RUNNER_PORT} + volumes: db_data: \ No newline at end of file diff --git a/mappers/testMapper.go b/mappers/testMapper.go new file mode 100644 index 0000000..adb5f74 --- /dev/null +++ b/mappers/testMapper.go @@ -0,0 +1,33 @@ +package mappers + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "rpl-service/models" +) + +func SolveExerciseRequestBody(exerciseCode, testScript string) io.Reader { + return bytes.NewReader([]byte(`{"script":"` + exerciseCode + "\", \"tests\":" + testScript + `"`)) +} + +func SolveExerciseResponseToResult(response *http.Response, exerciseName, testName string) models.ExerciseResult { + var exerciseResponse struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ReturnCode int `json:"returncode"` + } + err := json.NewDecoder(response.Body).Decode(&exerciseResponse) + if err != nil { + return models.ExerciseResult{} + } + + return models.ExerciseResult{ + ExerciseName: exerciseName, + TestName: testName, + TestPassed: exerciseResponse.ReturnCode == 0, + Stdout: exerciseResponse.Stdout, + Stderr: exerciseResponse.Stderr, + } +} diff --git a/models/courseModel.go b/models/courseModel.go index 2df76bb..2c438cc 100644 --- a/models/courseModel.go +++ b/models/courseModel.go @@ -2,7 +2,6 @@ package models import ( "github.com/google/uuid" - "github.com/lib/pq" "gorm.io/gorm" ) @@ -12,37 +11,3 @@ type Course struct { Name string `json:"name"` Description string `json:"description"` } - -type Exercise struct { - gorm.Model - ID uuid.UUID `gorm:"type:uuid;default:uuid_generate_v4()" json:"ID"` - Name string `json:"name"` - Description string `json:"description"` - BaseCode string `json:"base-code"` - Points int `json:"points"` - UnitNumber int `json:"unit_number"` - TestIDs pq.StringArray `json:"testIDs" gorm:"type:text[]"` -} - -type Test struct { - gorm.Model - ID uuid.UUID `gorm:"type:uuid;default:uuid_generate_v4()" json:"ID"` - Name string `json:"name"` - Input pq.StringArray `json:"input" gorm:"type:text[]"` - Output pq.StringArray `json:"output" gorm:"type:text[]"` -} - -type TestDTO struct { - Name string `json:"name"` - Input []string `json:"input"` - Output []string `json:"output"` -} - -type ExerciseDTO struct { - Name string `json:"name"` - Description string `json:"description"` - BaseCode string `json:"base-code"` - Points int `json:"points"` - UnitNumber int `json:"unit_number"` - TestData []TestDTO `json:"test-data"` -} diff --git a/models/exerciseModel.go b/models/exerciseModel.go new file mode 100644 index 0000000..322e5de --- /dev/null +++ b/models/exerciseModel.go @@ -0,0 +1,52 @@ +package models + +import ( + "github.com/google/uuid" + "github.com/lib/pq" + "gorm.io/gorm" +) + +type SolveExerciseResponse struct { + ExerciseCode string `json:"code"` +} + +type ExerciseResult struct { + ExerciseName string + TestName string + TestPassed bool + Stdout string + Stderr string +} +type Exercise struct { + gorm.Model + ID uuid.UUID `gorm:"type:uuid;default:uuid_generate_v4()" json:"ID"` + Name string `json:"name"` + Description string `json:"description"` + BaseCode string `json:"base-code"` + Points int `json:"points"` + UnitNumber int `json:"unit_number"` + TestIDs pq.StringArray `json:"testIDs" gorm:"type:text[]"` +} + +type Test struct { + gorm.Model + ID uuid.UUID `gorm:"type:uuid;default:uuid_generate_v4()" json:"ID"` + Name string `json:"name"` + TestScript string `json:"testScript"` +} + +// TestDTO Should remove DTOs? +type TestDTO struct { + Name string `json:"name"` + TestScript string `json:"testScript"` +} + +// ExerciseDTO Should remove DTOs? +type ExerciseDTO struct { + Name string `json:"name"` + Description string `json:"description"` + BaseCode string `json:"base-code"` + Points int `json:"points"` + UnitNumber int `json:"unit_number"` + TestData []TestDTO `json:"test-data"` +} diff --git a/services/exercises/exerciseService.go b/services/exercises/exerciseService.go new file mode 100644 index 0000000..52ce6fa --- /dev/null +++ b/services/exercises/exerciseService.go @@ -0,0 +1,104 @@ +package exercises + +import ( + "errors" + "github.com/google/uuid" + "gorm.io/gorm" + "net/http" + "os" + "rpl-service/mappers" + "rpl-service/models" + "rpl-service/services/users" +) + +func FindExercise(exerciseID uuid.UUID, db *gorm.DB) (models.Exercise, error) { + var exercise models.Exercise + db.Model(models.Exercise{}).Where(&exercise, "ID = ?", exerciseID) + if exercise.Name == "" { + return models.Exercise{}, errors.New("no exercise with that ID") + } + return exercise, nil +} + +func CreateExercise(db *gorm.DB, exercise models.ExerciseDTO, userID, courseID uuid.UUID) error { + if !users.IsOwner(db, userID, courseID) { + return errors.New("this user doesn't have permission to create an exercise") + } + + var testIDs []string + for _, test := range exercise.TestData { + testIDs = append(testIDs, CreateTest(db, test).String()) + } + + db.Model(models.Exercise{}).Create(models.Exercise{ + Model: gorm.Model{}, + Name: exercise.Name, + Description: exercise.Description, + BaseCode: exercise.BaseCode, + TestIDs: testIDs, + Points: exercise.Points, + UnitNumber: exercise.UnitNumber, + }) + + return nil +} + +func CreateTest(db *gorm.DB, test models.TestDTO) uuid.UUID { + db.Model(models.Test{}).Create(models.Test{ + Model: gorm.Model{}, + Name: test.Name, + TestScript: test.TestScript, + }) + + var currentTestID uuid.UUID + db.Model(models.Test{}).Select("ID").Last(¤tTestID) + + return currentTestID +} + +// SolveExercise exerciseCode should come from the request. +func SolveExercise(exerciseUUID uuid.UUID, db *gorm.DB, exerciseCode string) ([]models.ExerciseResult, error) { + tests := getTestsByExerciseID(exerciseUUID, db) + exercise, err := FindExercise(exerciseUUID, db) + + if err != nil { + return []models.ExerciseResult{}, err + } + + var testResults []models.ExerciseResult + codeRunnerURL := os.Getenv("RUNNER_URL") + + for _, test := range tests { + reader := mappers.SolveExerciseRequestBody(exerciseCode, test.TestScript) + response, postErr := http.Post(codeRunnerURL+"/run-tests", "application/json", reader) + if postErr != nil { + return []models.ExerciseResult{}, postErr + } + testResults = append(testResults, mappers.SolveExerciseResponseToResult(response, exercise.Name, test.Name)) + closeErr := response.Body.Close() + if closeErr != nil { + return nil, closeErr + } + } + + return testResults, nil +} + +// Private methods + +func getTestsByExerciseID(exerciseUUID uuid.UUID, db *gorm.DB) []models.Test { + var exercise models.Exercise + db.First(&exercise, exerciseUUID) + + var tests []models.Test + + // Now I should have the exercise, so I shall get all tests + for _, testID := range exercise.TestIDs { + // TODO: see gorm docs and retrieve multiple rows + var test models.Test + db.Model(models.Test{}).Where(&test, "ID = ?", testID) + tests = append(tests, test) + } + + return tests +} diff --git a/services/users/userService.go b/services/users/userService.go index abd2a16..c076aef 100644 --- a/services/users/userService.go +++ b/services/users/userService.go @@ -53,7 +53,7 @@ func CreateCourse(db *gorm.DB, userID uuid.UUID, courseName, description string) } func RemoveStudent(db *gorm.DB, userID, courseID, studentID uuid.UUID) error { - if !isOwner(db, userID, courseID) { + if !IsOwner(db, userID, courseID) { return errors.New("this user doesn't have permission to remove any student") } @@ -68,43 +68,6 @@ func RemoveStudent(db *gorm.DB, userID, courseID, studentID uuid.UUID) error { return nil } -func CreateExercise(db *gorm.DB, exercise models.ExerciseDTO, userID, courseID uuid.UUID) error { - if !isOwner(db, userID, courseID) { - return errors.New("this user doesn't have permission to create an exercise") - } - - var testIDs []string - for _, test := range exercise.TestData { - testIDs = append(testIDs, CreateTest(db, test).String()) - } - - db.Model(models.Exercise{}).Create(models.Exercise{ - Model: gorm.Model{}, - Name: exercise.Name, - Description: exercise.Description, - BaseCode: exercise.BaseCode, - TestIDs: testIDs, - Points: exercise.Points, - UnitNumber: exercise.UnitNumber, - }) - - return nil -} - -func CreateTest(db *gorm.DB, test models.TestDTO) uuid.UUID { - db.Model(models.Test{}).Create(models.Test{ - Model: gorm.Model{}, - Name: test.Name, - Input: test.Input, - Output: test.Output, - }) - - var currentTestID uuid.UUID - db.Model(models.Test{}).Select("ID").Last(¤tTestID) - - return currentTestID -} - func IsUserInCourse(db *gorm.DB, userID, courseID uuid.UUID) bool { if !CourseExists(db, courseID) { return false @@ -123,7 +86,7 @@ func CourseExists(db *gorm.DB, courseID uuid.UUID) bool { // return true //} -func isOwner(db *gorm.DB, userID, courseID uuid.UUID) bool { +func IsOwner(db *gorm.DB, userID, courseID uuid.UUID) bool { currentUser := models.IsEnrolled{} db.Model(models.IsEnrolled{}).Where("UserID = ? AND CourseID = ?", userID, courseID).First(¤tUser) return currentUser.IsOwner