diff --git a/app/V1Module/presenters/AssignmentSolutionReviewsPresenter.php b/app/V1Module/presenters/AssignmentSolutionReviewsPresenter.php index 5529b0c8b..181a6634e 100644 --- a/app/V1Module/presenters/AssignmentSolutionReviewsPresenter.php +++ b/app/V1Module/presenters/AssignmentSolutionReviewsPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\BadRequestException; use App\Exceptions\InternalServerException; use App\Exceptions\InvalidArgumentException; @@ -88,9 +101,9 @@ public function checkDefault(string $id) /** * Get detail of the solution and a list of review comments. * @GET - * @param string $id identifier of the solution * @throws InternalServerException */ + #[Path("id", new VString(), "identifier of the solution", required: true)] public function actionDefault(string $id) { $solution = $this->assignmentSolutions->findOrThrow($id); @@ -111,11 +124,10 @@ public function checkUpdate(string $id) /** * Update the state of the review process of the solution. * @POST - * @Param(type="post", name="close", validation="bool" - * description="If true, the review is closed. If false, the review is (re)opened.") - * @param string $id identifier of the solution * @throws InternalServerException */ + #[Post("close", new VBool(), "If true, the review is closed. If false, the review is (re)opened.")] + #[Path("id", new VString(), "identifier of the solution", required: true)] public function actionUpdate(string $id) { $solution = $this->assignmentSolutions->findOrThrow($id); @@ -182,9 +194,9 @@ public function checkRemove(string $id) /** * Update the state of the review process of the solution. * @DELETE - * @param string $id identifier of the solution * @throws InternalServerException */ + #[Path("id", new VString(), "identifier of the solution", required: true)] public function actionRemove(string $id) { $solution = $this->assignmentSolutions->findOrThrow($id); @@ -251,18 +263,29 @@ private function verifyCodeLocation(AssignmentSolution $solution, string $file, /** * Create a new comment within a review. * @POST - * @Param(type="post", name="text", validation="string:1..65535", required=true, description="The comment itself.") - * @Param(type="post", name="file", validation="string:0..256", required=true, - * description="Identification of the file to which the comment is related to.") - * @Param(type="post", name="line", validation="numericint", required=true, - * description="Line in the designated file to which the comment is related to.") - * @Param(type="post", name="issue", validation="bool", required=false, - * description="Whether the comment is an issue (expected to be resolved by the student)") - * @Param(type="post", name="suppressNotification", validation="bool", required=false, - * description="If true, no email notification will be sent (only applies when the review has been closed)") - * @param string $id identifier of the solution * @throws InternalServerException */ + #[Post("text", new VString(1, 65535), "The comment itself.", required: true)] + #[Post( + "file", + new VString(0, 256), + "Identification of the file to which the comment is related to.", + required: true, + )] + #[Post("line", new VInt(), "Line in the designated file to which the comment is related to.", required: true)] + #[Post( + "issue", + new VBool(), + "Whether the comment is an issue (expected to be resolved by the student)", + required: false, + )] + #[Post( + "suppressNotification", + new VBool(), + "If true, no email notification will be sent (only applies when the review has been closed)", + required: false, + )] + #[Path("id", new VString(), "identifier of the solution", required: true)] public function actionNewComment(string $id) { $solution = $this->assignmentSolutions->findOrThrow($id); @@ -320,15 +343,23 @@ public function checkEditComment(string $id, string $commentId) /** * Update existing comment within a review. * @POST - * @Param(type="post", name="text", validation="string:1..65535", required=true, description="The comment itself.") - * @Param(type="post", name="issue", validation="bool", required=false, - * description="Whether the comment is an issue (expected to be resolved by the student)") - * @Param(type="post", name="suppressNotification", validation="bool", required=false, - * description="If true, no email notification will be sent (only applies when the review has been closed)") - * @param string $id identifier of the solution - * @param string $commentId identifier of the review comment * @throws InternalServerException */ + #[Post("text", new VString(1, 65535), "The comment itself.", required: true)] + #[Post( + "issue", + new VBool(), + "Whether the comment is an issue (expected to be resolved by the student)", + required: false, + )] + #[Post( + "suppressNotification", + new VBool(), + "If true, no email notification will be sent (only applies when the review has been closed)", + required: false, + )] + #[Path("id", new VString(), "identifier of the solution", required: true)] + #[Path("commentId", new VString(), "identifier of the review comment", required: true)] public function actionEditComment(string $id, string $commentId) { $solution = $this->assignmentSolutions->findOrThrow($id); @@ -386,9 +417,9 @@ public function checkDeleteComment(string $id, string $commentId) /** * Remove one comment from a review. * @DELETE - * @param string $id identifier of the solution - * @param string $commentId identifier of the review comment */ + #[Path("id", new VString(), "identifier of the solution", required: true)] + #[Path("commentId", new VString(), "identifier of the review comment", required: true)] public function actionDeleteComment(string $id, string $commentId) { $comment = $this->reviewComments->findOrThrow($commentId); @@ -422,8 +453,8 @@ public function checkPending(string $id) * Return all solutions with pending reviews that given user teaches (is admin/supervisor in corresponding groups). * Along with that it returns all assignment entities of the corresponding solutions. * @GET - * @param string $id of the user whose pending reviews are listed */ + #[Path("id", new VString(), "of the user whose pending reviews are listed", required: true)] public function actionPending(string $id) { $user = $this->users->findOrThrow($id); diff --git a/app/V1Module/presenters/AssignmentSolutionsPresenter.php b/app/V1Module/presenters/AssignmentSolutionsPresenter.php index fff801893..f7fd82e61 100644 --- a/app/V1Module/presenters/AssignmentSolutionsPresenter.php +++ b/app/V1Module/presenters/AssignmentSolutionsPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\BadRequestException; use App\Exceptions\InternalServerException; use App\Exceptions\InvalidArgumentException; @@ -142,9 +155,9 @@ public function checkSolution(string $id) /** * Get information about solutions. * @GET - * @param string $id Identifier of the solution * @throws InternalServerException */ + #[Path("id", new VString(), "Identifier of the solution", required: true)] public function actionSolution(string $id) { $solution = $this->assignmentSolutions->findOrThrow($id); @@ -172,11 +185,11 @@ public function checkUpdateSolution(string $id) /** * Update details about the solution (note, etc...) * @POST - * @Param(type="post", name="note", validation="string:0..1024", description="A note by the author of the solution") - * @param string $id Identifier of the solution * @throws NotFoundException * @throws InternalServerException */ + #[Post("note", new VString(0, 1024), "A note by the author of the solution")] + #[Path("id", new VString(), "Identifier of the solution", required: true)] public function actionUpdateSolution(string $id) { $req = $this->getRequest(); @@ -198,9 +211,9 @@ public function checkDeleteSolution(string $id) /** * Delete assignment solution with given identification. * @DELETE - * @param string $id identifier of assignment solution * @throws ForbiddenRequestException */ + #[Path("id", new VString(), "identifier of assignment solution", required: true)] public function actionDeleteSolution(string $id) { $solution = $this->assignmentSolutions->findOrThrow($id); @@ -236,8 +249,8 @@ public function checkSubmissions(string $id) /** * Get list of all submissions of a solution * @GET - * @param string $id Identifier of the solution */ + #[Path("id", new VString(), "Identifier of the solution", required: true)] public function actionSubmissions(string $id) { $solution = $this->assignmentSolutions->findOrThrow($id); @@ -271,10 +284,10 @@ public function checkSubmission(string $submissionId) /** * Get information about the evaluation of a submission * @GET - * @param string $submissionId Identifier of the submission * @throws NotFoundException * @throws InternalServerException */ + #[Path("submissionId", new VString(), "Identifier of the submission", required: true)] public function actionSubmission(string $submissionId) { $submission = $this->assignmentSolutionSubmissions->findOrThrow($submissionId); @@ -301,8 +314,8 @@ public function checkDeleteSubmission(string $submissionId) /** * Remove the submission permanently * @DELETE - * @param string $submissionId Identifier of the submission */ + #[Path("submissionId", new VString(), "Identifier of the submission", required: true)] public function actionDeleteSubmission(string $submissionId) { $submission = $this->assignmentSolutionSubmissions->findOrThrow($submissionId); @@ -327,15 +340,19 @@ public function checkSetBonusPoints(string $id) * Set new amount of bonus points for a solution (and optionally points override) * Returns array of solution entities that has been changed by this. * @POST - * @Param(type="post", name="bonusPoints", validation="numericint", - * description="New amount of bonus points, can be negative number") - * @Param(type="post", name="overriddenPoints", required=false, - * description="Overrides points assigned to solution by the system") - * @param string $id Identifier of the solution * @throws NotFoundException * @throws InvalidArgumentException * @throws InvalidStateException */ + #[Post("bonusPoints", new VInt(), "New amount of bonus points, can be negative number")] + #[Post( + "overriddenPoints", + new VMixed(), + "Overrides points assigned to solution by the system", + required: false, + nullable: true, + )] + #[Path("id", new VString(), "Identifier of the solution", required: true)] public function actionSetBonusPoints(string $id) { $solution = $this->assignmentSolutions->findOrThrow($id); @@ -423,14 +440,13 @@ public function checkSetFlag(string $id, string $flag) /** * Set flag of the assignment solution. * @POST - * @param string $id identifier of the solution - * @param string $flag name of the flag which should to be changed - * @Param(type="post", name="value", required=true, validation=boolean, - * description="True or false which should be set to given flag name") * @throws NotFoundException * @throws \Nette\Application\AbortException * @throws \Exception */ + #[Post("value", new VBool(), "True or false which should be set to given flag name", required: true)] + #[Path("id", new VString(), "identifier of the solution", required: true)] + #[Path("flag", new VString(), "name of the flag which should to be changed", required: true)] public function actionSetFlag(string $id, string $flag) { $req = $this->getRequest(); @@ -546,12 +562,12 @@ public function checkDownloadSolutionArchive(string $id) /** * Download archive containing all solution files for particular solution. * @GET - * @param string $id of assignment solution * @throws ForbiddenRequestException * @throws NotFoundException * @throws \Nette\Application\BadRequestException * @throws \Nette\Application\AbortException */ + #[Path("id", new VString(), "of assignment solution", required: true)] public function actionDownloadSolutionArchive(string $id) { $solution = $this->assignmentSolutions->findOrThrow($id); @@ -573,10 +589,10 @@ public function checkFiles(string $id) /** * Get the list of submitted files of the solution. * @GET - * @param string $id of assignment solution * @throws ForbiddenRequestException * @throws NotFoundException */ + #[Path("id", new VString(), "of assignment solution", required: true)] public function actionFiles(string $id) { $solution = $this->assignmentSolutions->findOrThrow($id)->getSolution(); @@ -594,11 +610,11 @@ public function checkDownloadResultArchive(string $submissionId) /** * Download result archive from backend for particular submission. * @GET - * @param string $submissionId * @throws NotFoundException * @throws InternalServerException * @throws \Nette\Application\AbortException */ + #[Path("submissionId", new VString(), required: true)] public function actionDownloadResultArchive(string $submissionId) { $submission = $this->assignmentSolutionSubmissions->findOrThrow($submissionId); @@ -628,10 +644,10 @@ public function checkEvaluationScoreConfig(string $submissionId) /** * Get score configuration associated with given submission evaluation * @GET - * @param string $submissionId Identifier of the submission * @throws NotFoundException * @throws InternalServerException */ + #[Path("submissionId", new VString(), "Identifier of the submission", required: true)] public function actionEvaluationScoreConfig(string $submissionId) { $submission = $this->assignmentSolutionSubmissions->findOrThrow($submissionId); @@ -655,8 +671,8 @@ public function checkReviewRequests(string $id) * (is admin/supervisor in corresponding groups). * Along with that it returns all assignment entities of the corresponding solutions. * @GET - * @param string $id of the user whose solutions with requested reviews are listed */ + #[Path("id", new VString(), "of the user whose solutions with requested reviews are listed", required: true)] public function actionReviewRequests(string $id) { $user = $this->users->findOrThrow($id); diff --git a/app/V1Module/presenters/AssignmentSolversPresenter.php b/app/V1Module/presenters/AssignmentSolversPresenter.php index aa5dc7a60..48385c815 100644 --- a/app/V1Module/presenters/AssignmentSolversPresenter.php +++ b/app/V1Module/presenters/AssignmentSolversPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\BadRequestException; use App\Model\Entity\AssignmentSolutionSubmission; use App\Model\Repository\Assignments; @@ -92,11 +105,15 @@ public function checkDefault(?string $assignmentId, ?string $groupId, ?string $u * Get a list of assignment solvers based on given parameters (assignment/group and solver user). * Either assignment or group ID must be set (group is ignored if assignment is set), user ID is optional. * @GET - * @Param(type="query", name="assignmentId", required=false, validation="string:36") - * @Param(type="query", name="groupId", required=false, validation="string:36", - * description="An alternative for assignment ID, selects all assignments from a group.") - * @Param(type="query", name="userId", required=false, validation="string:36") */ + #[Query("assignmentId", new VUuid(), required: false)] + #[Query( + "groupId", + new VUuid(), + "An alternative for assignment ID, selects all assignments from a group.", + required: false, + )] + #[Query("userId", new VUuid(), required: false)] public function actionDefault(?string $assignmentId, ?string $groupId, ?string $userId): void { $user = $userId ? $this->users->findOrThrow($userId) : null; diff --git a/app/V1Module/presenters/AssignmentsPresenter.php b/app/V1Module/presenters/AssignmentsPresenter.php index 369017438..287b9931a 100644 --- a/app/V1Module/presenters/AssignmentsPresenter.php +++ b/app/V1Module/presenters/AssignmentsPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\BadRequestException; use App\Exceptions\ForbiddenRequestException; use App\Exceptions\InvalidArgumentException; @@ -177,8 +190,8 @@ public function checkDetail(string $id) /** * Get details of an assignment * @GET - * @param string $id Identifier of the assignment */ + #[Path("id", new VString(), "Identifier of the assignment", required: true)] public function actionDetail(string $id) { $this->sendSuccessResponse($this->assignmentViewFactory->getAssignment($this->assignments->findOrThrow($id))); @@ -195,53 +208,89 @@ public function checkUpdateDetail(string $id) /** * Update details of an assignment * @POST - * @Param(type="post", name="version", validation="numericint", description="Version of the edited assignment") - * @Param(type="post", name="isPublic", validation="bool", - * description="Is the assignment ready to be displayed to students?") - * @Param(type="post", name="localizedTexts", validation="array", description="A description of the assignment") - * @Param(type="post", name="firstDeadline", validation="timestamp", - * description="First deadline for submission of the assignment") - * @Param(type="post", name="maxPointsBeforeFirstDeadline", validation="numericint", - * description="A maximum of points that can be awarded for a submission before first deadline") - * @Param(type="post", name="submissionsCountLimit", validation="numericint", - * description="A maximum amount of submissions by a student for the assignment") - * @Param(type="post", name="solutionFilesLimit", validation="numericint|null", - * description="Maximal number of files in a solution being submitted") - * @Param(type="post", name="solutionSizeLimit", validation="numericint|null", - * description="Maximal size (bytes) of all files in a solution being submitted") - * @Param(type="post", name="allowSecondDeadline", validation="bool", - * description="Should there be a second deadline for students who didn't make the first one?") - * @Param(type="post", name="visibleFrom", validation="timestamp", required=false, - * description="Date from which this assignment will be visible to students") - * @Param(type="post", name="canViewLimitRatios", validation="bool", - * description="Can all users view ratio of theirs solution memory and time usages and assignment limits?") - * @Param(type="post", name="canViewMeasuredValues", validation="bool", - * description="Can all users view absolute memory and time values?") - * @Param(type="post", name="canViewJudgeStdout", validation="bool", - * description="Can all users view judge primary log (stdout) of theirs solution?") - * @Param(type="post", name="canViewJudgeStderr", validation="bool", - * description="Can all users view judge secondary log (stderr) of theirs solution?") - * @Param(type="post", name="secondDeadline", validation="timestamp", required=false, - * description="A second deadline for submission of the assignment (with different point award)") - * @Param(type="post", name="maxPointsBeforeSecondDeadline", validation="numericint", required=false, - * description="A maximum of points that can be awarded for a late submission") - * @Param(type="post", name="maxPointsDeadlineInterpolation", validation="bool", - * description="Use linear interpolation for max. points between 1st and 2nd deadline") - * @Param(type="post", name="isBonus", validation="bool", - * description="If true, points from this exercise will not be included in overall score of group") - * @Param(type="post", name="pointsPercentualThreshold", validation="numeric", required=false, - * description="A minimum percentage of points needed to gain point from assignment") - * @Param(type="post", name="disabledRuntimeEnvironmentIds", validation="list", required=false, - * description="Identifiers of runtime environments that should not be used for student submissions") - * @Param(type="post", name="sendNotification", required=false, validation="bool", - * description="If email notification (when assignment becomes public) should be sent") - * @Param(type="post", name="isExam", required=false, validation="bool", - * description="This assignemnt is dedicated for an exam (should be solved in exam mode)") - * @param string $id Identifier of the updated assignment * @throws BadRequestException * @throws InvalidArgumentException * @throws NotFoundException */ + #[Post("version", new VInt(), "Version of the edited assignment")] + #[Post("isPublic", new VBool(), "Is the assignment ready to be displayed to students?")] + #[Post("localizedTexts", new VArray(), "A description of the assignment")] + #[Post("firstDeadline", new VTimestamp(), "First deadline for submission of the assignment")] + #[Post( + "maxPointsBeforeFirstDeadline", + new VInt(), + "A maximum of points that can be awarded for a submission before first deadline", + )] + #[Post("submissionsCountLimit", new VInt(), "A maximum amount of submissions by a student for the assignment")] + #[Post("solutionFilesLimit", new VInt(), "Maximal number of files in a solution being submitted", nullable: true)] + #[Post( + "solutionSizeLimit", + new VInt(), + "Maximal size (bytes) of all files in a solution being submitted", + nullable: true, + )] + #[Post( + "allowSecondDeadline", + new VBool(), + "Should there be a second deadline for students who didn't make the first one?", + )] + #[Post( + "visibleFrom", + new VTimestamp(), + "Date from which this assignment will be visible to students", + required: false, + )] + #[Post( + "canViewLimitRatios", + new VBool(), + "Can all users view ratio of theirs solution memory and time usages and assignment limits?", + )] + #[Post("canViewMeasuredValues", new VBool(), "Can all users view absolute memory and time values?")] + #[Post("canViewJudgeStdout", new VBool(), "Can all users view judge primary log (stdout) of theirs solution?")] + #[Post("canViewJudgeStderr", new VBool(), "Can all users view judge secondary log (stderr) of theirs solution?")] + #[Post( + "secondDeadline", + new VTimestamp(), + "A second deadline for submission of the assignment (with different point award)", + required: false, + )] + #[Post( + "maxPointsBeforeSecondDeadline", + new VInt(), + "A maximum of points that can be awarded for a late submission", + required: false, + )] + #[Post( + "maxPointsDeadlineInterpolation", + new VBool(), + "Use linear interpolation for max. points between 1st and 2nd deadline", + )] + #[Post("isBonus", new VBool(), "If true, points from this exercise will not be included in overall score of group")] + #[Post( + "pointsPercentualThreshold", + new VDouble(), + "A minimum percentage of points needed to gain point from assignment", + required: false, + )] + #[Post( + "disabledRuntimeEnvironmentIds", + new VArray(), + "Identifiers of runtime environments that should not be used for student submissions", + required: false, + )] + #[Post( + "sendNotification", + new VBool(), + "If email notification (when assignment becomes public) should be sent", + required: false, + )] + #[Post( + "isExam", + new VBool(), + "This assignemnt is dedicated for an exam (should be solved in exam mode)", + required: false, + )] + #[Path("id", new VString(), "Identifier of the updated assignment", required: true)] public function actionUpdateDetail(string $id) { $assignment = $this->assignments->findOrThrow($id); @@ -471,10 +520,10 @@ public function checkValidate(string $id) /** * Check if the version of the assignment is up-to-date. * @POST - * @Param(type="post", name="version", validation="numericint", description="Version of the assignment.") - * @param string $id Identifier of the assignment * @throws ForbiddenRequestException */ + #[Post("version", new VInt(), "Version of the assignment.")] + #[Path("id", new VString(), "Identifier of the assignment", required: true)] public function actionValidate($id) { $assignment = $this->assignments->findOrThrow($id); @@ -492,13 +541,13 @@ public function actionValidate($id) /** * Assign an exercise to a group * @POST - * @Param(type="post", name="exerciseId", description="Identifier of the exercise") - * @Param(type="post", name="groupId", description="Identifier of the group") * @throws ForbiddenRequestException * @throws BadRequestException * @throws InvalidStateException * @throws NotFoundException */ + #[Post("exerciseId", new VMixed(), "Identifier of the exercise", nullable: true)] + #[Post("groupId", new VMixed(), "Identifier of the group", nullable: true)] public function actionCreate() { $req = $this->getRequest(); @@ -576,8 +625,8 @@ public function checkRemove(string $id) /** * Delete an assignment * @DELETE - * @param string $id Identifier of the assignment to be removed */ + #[Path("id", new VString(), "Identifier of the assignment to be removed", required: true)] public function actionRemove(string $id) { $this->assignments->remove($this->assignments->findOrThrow($id)); @@ -594,11 +643,11 @@ public function checkSyncWithExercise(string $id) /** * Update the assignment so that it matches with the current version of the exercise (limits, texts, etc.) - * @param string $id Identifier of the assignment that should be synchronized * @POST * @throws BadRequestException * @throws NotFoundException */ + #[Path("id", new VString(), "Identifier of the assignment that should be synchronized", required: true)] public function actionSyncWithExercise($id) { $assignment = $this->assignments->findOrThrow($id); @@ -631,9 +680,9 @@ public function checkSolutions(string $id) /** * Get a list of solutions of all users for the assignment * @GET - * @param string $id Identifier of the assignment * @throws NotFoundException */ + #[Path("id", new VString(), "Identifier of the assignment", required: true)] public function actionSolutions(string $id) { $assignment = $this->assignments->findOrThrow($id); @@ -665,9 +714,9 @@ public function checkUserSolutions(string $id, string $userId) /** * Get a list of solutions created by a user of an assignment * @GET - * @param string $id Identifier of the assignment - * @param string $userId Identifier of the user */ + #[Path("id", new VString(), "Identifier of the assignment", required: true)] + #[Path("userId", new VString(), "Identifier of the user", required: true)] public function actionUserSolutions(string $id, string $userId) { $assignment = $this->assignments->findOrThrow($id); @@ -708,10 +757,10 @@ public function checkBestSolution(string $id, string $userId) /** * Get the best solution by a user to an assignment * @GET - * @param string $id Identifier of the assignment - * @param string $userId Identifier of the user * @throws ForbiddenRequestException */ + #[Path("id", new VString(), "Identifier of the assignment", required: true)] + #[Path("userId", new VString(), "Identifier of the user", required: true)] public function actionBestSolution(string $id, string $userId) { $assignment = $this->assignments->findOrThrow($id); @@ -738,9 +787,9 @@ public function checkBestSolutions(string $id) /** * Get the best solutions to an assignment for all students in group. * @GET - * @param string $id Identifier of the assignment * @throws NotFoundException */ + #[Path("id", new VString(), "Identifier of the assignment", required: true)] public function actionBestSolutions(string $id) { $assignment = $this->assignments->findOrThrow($id); @@ -782,11 +831,11 @@ public function checkDownloadBestSolutionsArchive(string $id) /** * Download the best solutions of an assignment for all students in group. * @GET - * @param string $id Identifier of the assignment * @throws NotFoundException * @throws \Nette\Application\AbortException * @throws \Nette\Application\BadRequestException */ + #[Path("id", new VString(), "Identifier of the assignment", required: true)] public function actionDownloadBestSolutionsArchive(string $id) { $assignment = $this->assignments->findOrThrow($id); diff --git a/app/V1Module/presenters/AsyncJobsPresenter.php b/app/V1Module/presenters/AsyncJobsPresenter.php index bd1d9ffff..5b4602ad9 100644 --- a/app/V1Module/presenters/AsyncJobsPresenter.php +++ b/app/V1Module/presenters/AsyncJobsPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Async\Dispatcher; use App\Async\Handler\PingAsyncJobHandler; use App\Model\Repository\Assignments; @@ -65,9 +78,9 @@ public function checkDefault(string $id) /** * Retrieves details about particular async job. * @GET - * @param string $id job identifier * @throws NotFoundException */ + #[Path("id", new VString(), "job identifier", required: true)] public function actionDefault(string $id) { $asyncJob = $this->asyncJobs->findOrThrow($id); @@ -84,10 +97,22 @@ public function checkList() /** * Retrieves details about async jobs that are either pending or were recently completed. * @GET - * @param int|null $ageThreshold Maximal time since completion (in seconds), null = only pending operations - * @param bool|null $includeScheduled If true, pending scheduled events will be listed as well * @throws BadRequestException */ + #[Query( + "ageThreshold", + new VInt(), + "Maximal time since completion (in seconds), null = only pending operations", + required: false, + nullable: true, + )] + #[Query( + "includeScheduled", + new VBool(), + "If true, pending scheduled events will be listed as well", + required: false, + nullable: true, + )] public function actionList(?int $ageThreshold, ?bool $includeScheduled) { if ($ageThreshold && $ageThreshold < 0) { @@ -134,9 +159,9 @@ public function checkAbort(string $id) /** * Retrieves details about particular async job. * @POST - * @param string $id job identifier * @throws NotFoundException */ + #[Path("id", new VString(), "job identifier", required: true)] public function actionAbort(string $id) { $this->asyncJobs->beginTransaction(); @@ -188,6 +213,7 @@ public function checkAssignmentJobs($id) * Get all pending async jobs related to a particular assignment. * @GET */ + #[Path("id", new VString(), required: true)] public function actionAssignmentJobs($id) { $asyncJobs = $this->asyncJobs->findAssignmentJobs($id); diff --git a/app/V1Module/presenters/BrokerPresenter.php b/app/V1Module/presenters/BrokerPresenter.php index b208738c4..a28f16087 100644 --- a/app/V1Module/presenters/BrokerPresenter.php +++ b/app/V1Module/presenters/BrokerPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\ForbiddenRequestException; use App\Exceptions\InvalidStateException; use App\Helpers\BrokerProxy; diff --git a/app/V1Module/presenters/BrokerReportsPresenter.php b/app/V1Module/presenters/BrokerReportsPresenter.php index fc4cd4e86..8978d9e33 100644 --- a/app/V1Module/presenters/BrokerReportsPresenter.php +++ b/app/V1Module/presenters/BrokerReportsPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\HttpBasicAuthException; use App\Exceptions\InternalServerException; use App\Exceptions\InvalidStateException; @@ -167,13 +180,13 @@ private function processJobFailure(JobId $job) /** * Update the status of a job (meant to be called by the backend) * @POST - * @Param(name="status", type="post", description="The new status of the job") - * @Param(name="message", type="post", required=false, description="A textual explanation of the status change") - * @param string $jobId Identifier of the job whose status is being reported * @throws InternalServerException * @throws NotFoundException * @throws InvalidStateException */ + #[Post("status", new VMixed(), "The new status of the job", nullable: true)] + #[Post("message", new VMixed(), "A textual explanation of the status change", required: false, nullable: true)] + #[Path("jobId", new VString(), "Identifier of the job whose status is being reported", required: true)] public function actionJobStatus($jobId) { $status = $this->getRequest()->getPost("status"); @@ -196,9 +209,9 @@ public function actionJobStatus($jobId) /** * Announce a backend error that is not related to any job (meant to be called by the backend) * @POST - * @Param(name="message", type="post", description="A textual description of the error") * @throws InternalServerException */ + #[Post("message", new VMixed(), "A textual description of the error", nullable: true)] public function actionError() { $req = $this->getRequest(); diff --git a/app/V1Module/presenters/CommentsPresenter.php b/app/V1Module/presenters/CommentsPresenter.php index d1bb8b774..5f4396b24 100644 --- a/app/V1Module/presenters/CommentsPresenter.php +++ b/app/V1Module/presenters/CommentsPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\ForbiddenRequestException; use App\Exceptions\NotFoundException; use App\Helpers\Notifications\SolutionCommentsEmailsSender; @@ -95,9 +108,9 @@ public function checkDefault($id) /** * Get a comment thread * @GET - * @param string $id Identifier of the comment thread * @throws ForbiddenRequestException */ + #[Path("id", new VString(), "Identifier of the comment thread", required: true)] public function actionDefault($id) { $thread = $this->findThreadOrCreateIt($id); @@ -123,12 +136,11 @@ public function checkAddComment(string $id) /** * Add a comment to a thread * @POST - * @Param(type="post", name="text", validation="string:1..65535", description="Text of the comment") - * @Param(type="post", name="isPrivate", validation="bool", required=false, - * description="True if the comment is private") - * @param string $id Identifier of the comment thread * @throws ForbiddenRequestException */ + #[Post("text", new VString(1, 65535), "Text of the comment")] + #[Post("isPrivate", new VBool(), "True if the comment is private", required: false)] + #[Path("id", new VString(), "Identifier of the comment thread", required: true)] public function actionAddComment(string $id) { $thread = $this->findThreadOrCreateIt($id); @@ -178,10 +190,10 @@ public function checkTogglePrivate(string $threadId, string $commentId) * Make a private comment public or vice versa * @DEPRECATED * @POST - * @param string $threadId Identifier of the comment thread - * @param string $commentId Identifier of the comment * @throws NotFoundException */ + #[Path("threadId", new VString(), "Identifier of the comment thread", required: true)] + #[Path("commentId", new VString(), "Identifier of the comment", required: true)] public function actionTogglePrivate(string $threadId, string $commentId) { /** @var Comment $comment */ @@ -211,11 +223,11 @@ public function checkSetPrivate(string $threadId, string $commentId) /** * Set the private flag of a comment * @POST - * @param string $threadId Identifier of the comment thread - * @param string $commentId Identifier of the comment - * @Param(type="post", name="isPrivate", validation="bool", description="True if the comment is private") * @throws NotFoundException */ + #[Post("isPrivate", new VBool(), "True if the comment is private")] + #[Path("threadId", new VString(), "Identifier of the comment thread", required: true)] + #[Path("commentId", new VString(), "Identifier of the comment", required: true)] public function actionSetPrivate(string $threadId, string $commentId) { /** @var Comment $comment */ @@ -248,11 +260,11 @@ public function checkDelete(string $threadId, string $commentId) /** * Delete a comment * @DELETE - * @param string $threadId Identifier of the comment thread - * @param string $commentId Identifier of the comment * @throws ForbiddenRequestException * @throws NotFoundException */ + #[Path("threadId", new VString(), "Identifier of the comment thread", required: true)] + #[Path("commentId", new VString(), "Identifier of the comment", required: true)] public function actionDelete(string $threadId, string $commentId) { /** @var Comment $comment */ diff --git a/app/V1Module/presenters/DefaultPresenter.php b/app/V1Module/presenters/DefaultPresenter.php index 39a4427b7..2e4525cc7 100644 --- a/app/V1Module/presenters/DefaultPresenter.php +++ b/app/V1Module/presenters/DefaultPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Helpers\ApiConfig; class DefaultPresenter extends BasePresenter diff --git a/app/V1Module/presenters/EmailVerificationPresenter.php b/app/V1Module/presenters/EmailVerificationPresenter.php index 8448378d8..2c8946d4a 100644 --- a/app/V1Module/presenters/EmailVerificationPresenter.php +++ b/app/V1Module/presenters/EmailVerificationPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\ForbiddenRequestException; use App\Helpers\EmailVerificationHelper; use App\Security\Identity; diff --git a/app/V1Module/presenters/EmailsPresenter.php b/app/V1Module/presenters/EmailsPresenter.php index 3736dcdd9..2e683f5fb 100644 --- a/app/V1Module/presenters/EmailsPresenter.php +++ b/app/V1Module/presenters/EmailsPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\ForbiddenRequestException; use App\Exceptions\NotFoundException; use App\Helpers\EmailHelper; @@ -55,10 +68,9 @@ public function checkDefault() /** * Sends an email with provided subject and message to all ReCodEx users. * @POST - * @Param(type="post", name="subject", validation="string:1..", description="Subject for the soon to be sent email") - * @Param(type="post", name="message", validation="string:1..", - * description="Message which will be sent, can be html code") */ + #[Post("subject", new VString(1), "Subject for the soon to be sent email")] + #[Post("message", new VString(1), "Message which will be sent, can be html code")] public function actionDefault() { $users = $this->users->findAll(); @@ -86,10 +98,9 @@ public function checkSendToSupervisors() /** * Sends an email with provided subject and message to all supervisors and superadmins. * @POST - * @Param(type="post", name="subject", validation="string:1..", description="Subject for the soon to be sent email") - * @Param(type="post", name="message", validation="string:1..", - * description="Message which will be sent, can be html code") */ + #[Post("subject", new VString(1), "Subject for the soon to be sent email")] + #[Post("message", new VString(1), "Message which will be sent, can be html code")] public function actionSendToSupervisors() { $supervisors = $this->users->findByRoles( @@ -123,10 +134,9 @@ public function checkSendToRegularUsers() /** * Sends an email with provided subject and message to all regular users. * @POST - * @Param(type="post", name="subject", validation="string:1..", description="Subject for the soon to be sent email") - * @Param(type="post", name="message", validation="string:1..", - * description="Message which will be sent, can be html code") */ + #[Post("subject", new VString(1), "Subject for the soon to be sent email")] + #[Post("message", new VString(1), "Message which will be sent, can be html code")] public function actionSendToRegularUsers() { $users = $this->users->findByRoles(Roles::STUDENT_ROLE, Roles::SUPERVISOR_STUDENT_ROLE); @@ -156,20 +166,16 @@ public function checkSendToGroupMembers(string $groupId) * Sends an email with provided subject and message to regular members of * given group and optionally to supervisors and admins. * @POST - * @param string $groupId - * @Param(type="post", name="toSupervisors", validation="bool", required=false, - * description="If true, then the mail will be sent to supervisors") - * @Param(type="post", name="toAdmins", validation="bool", required=false, - * description="If the mail should be sent also to primary admins") - * @Param(type="post", name="toObservers", validation="bool", required=false, - * description="If the mail should be sent also to observers") - * @Param(type="post", name="toMe", validation="bool", description="User wants to also receive an email") - * @Param(type="post", name="subject", validation="string:1..", description="Subject for the soon to be sent email") - * @Param(type="post", name="message", validation="string:1..", - * description="Message which will be sent, can be html code") * @throws NotFoundException * @throws ForbiddenRequestException */ + #[Post("toSupervisors", new VBool(), "If true, then the mail will be sent to supervisors", required: false)] + #[Post("toAdmins", new VBool(), "If the mail should be sent also to primary admins", required: false)] + #[Post("toObservers", new VBool(), "If the mail should be sent also to observers", required: false)] + #[Post("toMe", new VBool(), "User wants to also receive an email")] + #[Post("subject", new VString(1), "Subject for the soon to be sent email")] + #[Post("message", new VString(1), "Message which will be sent, can be html code")] + #[Path("groupId", new VString(), required: true)] public function actionSendToGroupMembers(string $groupId) { $user = $this->getCurrentUser(); diff --git a/app/V1Module/presenters/ExerciseFilesPresenter.php b/app/V1Module/presenters/ExerciseFilesPresenter.php index 7ec04d81c..538f3a5cf 100644 --- a/app/V1Module/presenters/ExerciseFilesPresenter.php +++ b/app/V1Module/presenters/ExerciseFilesPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\ForbiddenRequestException; use App\Exceptions\InvalidArgumentException; use App\Exceptions\NotFoundException; @@ -85,12 +98,12 @@ public function checkUploadSupplementaryFiles(string $id) /** * Associate supplementary files with an exercise and upload them to remote file server * @POST - * @Param(type="post", name="files", description="Identifiers of supplementary files") - * @param string $id identification of exercise * @throws ForbiddenRequestException * @throws InvalidArgumentException * @throws SubmissionFailedException */ + #[Post("files", new VMixed(), "Identifiers of supplementary files", nullable: true)] + #[Path("id", new VString(), "identification of exercise", required: true)] public function actionUploadSupplementaryFiles(string $id) { $exercise = $this->exercises->findOrThrow($id); @@ -170,8 +183,8 @@ public function checkGetSupplementaryFiles(string $id) /** * Get list of all supplementary files for an exercise * @GET - * @param string $id identification of exercise */ + #[Path("id", new VString(), "identification of exercise", required: true)] public function actionGetSupplementaryFiles(string $id) { $exercise = $this->exercises->findOrThrow($id); @@ -189,10 +202,10 @@ public function checkDeleteSupplementaryFile(string $id, string $fileId) /** * Delete supplementary exercise file with given id * @DELETE - * @param string $id identification of exercise - * @param string $fileId identification of file * @throws ForbiddenRequestException */ + #[Path("id", new VString(), "identification of exercise", required: true)] + #[Path("fileId", new VString(), "identification of file", required: true)] public function actionDeleteSupplementaryFile(string $id, string $fileId) { $exercise = $this->exercises->findOrThrow($id); @@ -219,12 +232,12 @@ public function checkDownloadSupplementaryFilesArchive(string $id) /** * Download archive containing all supplementary files for exercise. * @GET - * @param string $id of exercise * @throws ForbiddenRequestException * @throws NotFoundException * @throws \Nette\Application\BadRequestException * @throws \Nette\Application\AbortException */ + #[Path("id", new VString(), "of exercise", required: true)] public function actionDownloadSupplementaryFilesArchive(string $id) { $exercise = $this->exercises->findOrThrow($id); @@ -248,10 +261,10 @@ public function checkUploadAttachmentFiles(string $id) /** * Associate attachment exercise files with an exercise * @POST - * @Param(type="post", name="files", description="Identifiers of attachment files") - * @param string $id identification of exercise * @throws ForbiddenRequestException */ + #[Post("files", new VMixed(), "Identifiers of attachment files", nullable: true)] + #[Path("id", new VString(), "identification of exercise", required: true)] public function actionUploadAttachmentFiles(string $id) { $exercise = $this->exercises->findOrThrow($id); @@ -304,9 +317,9 @@ public function checkGetAttachmentFiles(string $id) /** * Get a list of all attachment files for an exercise * @GET - * @param string $id identification of exercise * @throws ForbiddenRequestException */ + #[Path("id", new VString(), "identification of exercise", required: true)] public function actionGetAttachmentFiles(string $id) { /** @var Exercise $exercise */ @@ -326,11 +339,11 @@ public function checkDeleteAttachmentFile(string $id, string $fileId) /** * Delete attachment exercise file with given id * @DELETE - * @param string $id identification of exercise - * @param string $fileId identification of file * @throws ForbiddenRequestException * @throws NotFoundException */ + #[Path("id", new VString(), "identification of exercise", required: true)] + #[Path("fileId", new VString(), "identification of file", required: true)] public function actionDeleteAttachmentFile(string $id, string $fileId) { $exercise = $this->exercises->findOrThrow($id); @@ -356,11 +369,11 @@ public function checkDownloadAttachmentFilesArchive(string $id) /** * Download archive containing all attachment files for exercise. * @GET - * @param string $id of exercise * @throws NotFoundException * @throws \Nette\Application\BadRequestException * @throws \Nette\Application\AbortException */ + #[Path("id", new VString(), "of exercise", required: true)] public function actionDownloadAttachmentFilesArchive(string $id) { $exercise = $this->exercises->findOrThrow($id); diff --git a/app/V1Module/presenters/ExercisesConfigPresenter.php b/app/V1Module/presenters/ExercisesConfigPresenter.php index 167fe02b7..90cda8602 100644 --- a/app/V1Module/presenters/ExercisesConfigPresenter.php +++ b/app/V1Module/presenters/ExercisesConfigPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\ApiException; use App\Exceptions\ExerciseCompilationException; use App\Exceptions\ExerciseConfigException; @@ -159,9 +172,9 @@ private function getEnvironmentConfigs(Exercise $exercise) /** * Get runtime configurations for exercise. * @GET - * @param string $id Identifier of the exercise * @throws NotFoundException */ + #[Path("id", new VString(), "Identifier of the exercise", required: true)] public function actionGetEnvironmentConfigs(string $id) { /** @var Exercise $exercise */ @@ -183,14 +196,13 @@ public function checkUpdateEnvironmentConfigs(string $id) * Change runtime configuration of exercise. * Configurations can be added or deleted here, based on what is provided in arguments. * @POST - * @param string $id identification of exercise - * @Param(type="post", name="environmentConfigs", validation="array", - * description="Environment configurations for the exercise") * @throws ForbiddenRequestException * @throws InvalidArgumentException * @throws ExerciseConfigException * @throws NotFoundException */ + #[Post("environmentConfigs", new VArray(), "Environment configurations for the exercise")] + #[Path("id", new VString(), "identification of exercise", required: true)] public function actionUpdateEnvironmentConfigs(string $id) { /** @var Exercise $exercise */ @@ -269,10 +281,10 @@ public function checkGetConfiguration(string $id) /** * Get a basic exercise high level configuration. * @GET - * @param string $id Identifier of the exercise * @throws NotFoundException * @throws ExerciseConfigException */ + #[Path("id", new VString(), "Identifier of the exercise", required: true)] public function actionGetConfiguration(string $id) { /** @var Exercise $exercise */ @@ -302,15 +314,14 @@ public function checkSetConfiguration(string $id) /** * Set basic exercise configuration * @POST - * @Param(type="post", name="config", validation="array", - * description="A list of basic high level exercise configuration") - * @param string $id Identifier of the exercise * @throws ExerciseConfigException * @throws ForbiddenRequestException * @throws NotFoundException * @throws ApiException * @throws ParseException */ + #[Post("config", new VArray(), "A list of basic high level exercise configuration")] + #[Path("id", new VString(), "Identifier of the exercise", required: true)] public function actionSetConfiguration(string $id) { $exercise = $this->exercises->findOrThrow($id); @@ -359,14 +370,12 @@ public function checkGetVariablesForExerciseConfig(string $id) * Get variables for exercise configuration which are derived from given * pipelines and runtime environment. * @POST - * @param string $id Identifier of the exercise - * @Param(type="post", name="runtimeEnvironmentId", validation="string:1..", required=false, - * description="Environment identifier") - * @Param(type="post", name="pipelinesIds", validation="array", - * description="Identifiers of selected pipelines for one test") * @throws NotFoundException * @throws ExerciseConfigException */ + #[Post("runtimeEnvironmentId", new VString(1), "Environment identifier", required: false)] + #[Post("pipelinesIds", new VArray(), "Identifiers of selected pipelines for one test")] + #[Path("id", new VString(), "Identifier of the exercise", required: true)] public function actionGetVariablesForExerciseConfig(string $id) { // get request data @@ -406,13 +415,13 @@ public function checkGetHardwareGroupLimits(string $id, string $runtimeEnvironme * Get a description of resource limits for an exercise for given hwgroup. * @DEPRECATED * @GET - * @param string $id Identifier of the exercise - * @param string $runtimeEnvironmentId - * @param string $hwGroupId * @throws ForbiddenRequestException * @throws NotFoundException * @throws ExerciseConfigException */ + #[Path("id", new VString(), "Identifier of the exercise", required: true)] + #[Path("runtimeEnvironmentId", new VString(), required: true)] + #[Path("hwGroupId", new VString(), required: true)] public function actionGetHardwareGroupLimits(string $id, string $runtimeEnvironmentId, string $hwGroupId) { /** @var Exercise $exercise */ @@ -451,11 +460,6 @@ public function checkSetHardwareGroupLimits(string $id, string $runtimeEnvironme * Set resource limits for an exercise for given hwgroup. * @DEPRECATED * @POST - * @Param(type="post", name="limits", validation="array", - * description="A list of resource limits for the given environment and hardware group") - * @param string $id Identifier of the exercise - * @param string $runtimeEnvironmentId - * @param string $hwGroupId * @throws ApiException * @throws ExerciseConfigException * @throws ForbiddenRequestException @@ -463,6 +467,10 @@ public function checkSetHardwareGroupLimits(string $id, string $runtimeEnvironme * @throws ParseException * @throws ExerciseCompilationException */ + #[Post("limits", new VArray(), "A list of resource limits for the given environment and hardware group")] + #[Path("id", new VString(), "Identifier of the exercise", required: true)] + #[Path("runtimeEnvironmentId", new VString(), required: true)] + #[Path("hwGroupId", new VString(), required: true)] public function actionSetHardwareGroupLimits(string $id, string $runtimeEnvironmentId, string $hwGroupId) { /** @var Exercise $exercise */ @@ -522,11 +530,11 @@ public function checkRemoveHardwareGroupLimits(string $id, string $runtimeEnviro * Remove resource limits of given hwgroup from an exercise. * @DEPRECATED * @DELETE - * @param string $id Identifier of the exercise - * @param string $runtimeEnvironmentId - * @param string $hwGroupId * @throws NotFoundException */ + #[Path("id", new VString(), "Identifier of the exercise", required: true)] + #[Path("runtimeEnvironmentId", new VString(), required: true)] + #[Path("hwGroupId", new VString(), required: true)] public function actionRemoveHardwareGroupLimits(string $id, string $runtimeEnvironmentId, string $hwGroupId) { /** @var Exercise $exercise */ @@ -565,10 +573,10 @@ public function checkGetLimits(string $id) /** * Get a description of resource limits for given exercise (all hwgroups all environments). * @GET - * @param string $id Identifier of the exercise * @throws ForbiddenRequestException * @throws NotFoundException */ + #[Path("id", new VString(), "Identifier of the exercise", required: true)] public function actionGetLimits(string $id) { /** @var Exercise $exercise */ @@ -605,13 +613,12 @@ public function checkSetLimits(string $id) * If limits for particular hwGroup or environment are not posted, no change occurs. * If limits for particular hwGroup or environment are posted as null, they are removed. * @POST - * @Param(type="post", name="limits", validation="array", - * description="A list of resource limits in the same format as getLimits endpoint yields.") - * @param string $id Identifier of the exercise * @throws ForbiddenRequestException * @throws NotFoundException * @throws ExerciseConfigException */ + #[Post("limits", new VArray(), "A list of resource limits in the same format as getLimits endpoint yields.")] + #[Path("id", new VString(), "Identifier of the exercise", required: true)] public function actionSetLimits(string $id) { /** @var Exercise $exercise */ @@ -669,8 +676,8 @@ public function checkGetScoreConfig(string $id) /** * Get score configuration for exercise based on given identification. * @GET - * @param string $id Identifier of the exercise */ + #[Path("id", new VString(), "Identifier of the exercise", required: true)] public function actionGetScoreConfig(string $id) { $exercise = $this->exercises->findOrThrow($id); @@ -690,12 +697,16 @@ public function checkSetScoreConfig(string $id) /** * Set score configuration for exercise. * @POST - * @Param(type="post", name="scoreCalculator", validation="string", description="ID of the score calculator") - * @Param(type="post", name="scoreConfig", - * description="A configuration of the score calculator (the format depends on the calculator type)") - * @param string $id Identifier of the exercise * @throws ExerciseConfigException */ + #[Post("scoreCalculator", new VString(), "ID of the score calculator")] + #[Post( + "scoreConfig", + new VMixed(), + "A configuration of the score calculator (the format depends on the calculator type)", + nullable: true, + )] + #[Path("id", new VString(), "Identifier of the exercise", required: true)] public function actionSetScoreConfig(string $id) { $exercise = $this->exercises->findOrThrow($id); @@ -733,8 +744,8 @@ public function checkGetTests(string $id) /** * Get tests for exercise based on given identification. * @GET - * @param string $id Identifier of the exercise */ + #[Path("id", new VString(), "Identifier of the exercise", required: true)] public function actionGetTests(string $id) { $exercise = $this->exercises->findOrThrow($id); @@ -754,13 +765,12 @@ public function checkSetTests(string $id) /** * Set tests for exercise based on given identification. * @POST - * @param string $id Identifier of the exercise - * @Param(type="post", name="tests", validation="array", - * description="An array of tests which will belong to exercise") * @throws ForbiddenRequestException * @throws InvalidArgumentException * @throws ExerciseConfigException */ + #[Post("tests", new VArray(), "An array of tests which will belong to exercise")] + #[Path("id", new VString(), "Identifier of the exercise", required: true)] public function actionSetTests(string $id) { $exercise = $this->exercises->findOrThrow($id); diff --git a/app/V1Module/presenters/ExercisesPresenter.php b/app/V1Module/presenters/ExercisesPresenter.php index 15c01613d..82e58bdf7 100644 --- a/app/V1Module/presenters/ExercisesPresenter.php +++ b/app/V1Module/presenters/ExercisesPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\ApiException; use App\Exceptions\BadRequestException; use App\Exceptions\ForbiddenRequestException; @@ -184,12 +197,24 @@ public function checkDefault() * Get a list of all exercises matching given filters in given pagination rage. * The result conforms to pagination protocol. * @GET - * @param int $offset Index of the first result. - * @param int|null $limit Maximal number of results returned. - * @param string|null $orderBy Name of the column (column concept). The '!' prefix indicate descending order. - * @param array|null $filters Named filters that prune the result. - * @param string|null $locale Currently set locale (used to augment order by clause if necessary), */ + #[Query("offset", new VInt(), "Index of the first result.", required: false)] + #[Query("limit", new VInt(), "Maximal number of results returned.", required: false, nullable: true)] + #[Query( + "orderBy", + new VString(), + "Name of the column (column concept). The '!' prefix indicate descending order.", + required: false, + nullable: true, + )] + #[Query("filters", new VArray(), "Named filters that prune the result.", required: false, nullable: true)] + #[Query( + "locale", + new VString(), + "Currently set locale (used to augment order by clause if necessary),", + required: false, + nullable: true, + )] public function actionDefault( int $offset = 0, int $limit = null, @@ -238,9 +263,15 @@ public function checkAuthors() /** * List authors of all exercises, possibly filtered by a group in which the exercises appear. * @GET - * @param string $instanceId Id of an instance from which the authors are listed. - * @param string|null $groupId A group where the relevant exercises can be seen (assigned). */ + #[Query("instanceId", new VString(), "Id of an instance from which the authors are listed.", required: false)] + #[Query( + "groupId", + new VString(), + "A group where the relevant exercises can be seen (assigned).", + required: false, + nullable: true, + )] public function actionAuthors(string $instanceId = null, string $groupId = null) { $authors = $this->exercises->getAuthors($instanceId, $groupId, $this->groups); @@ -257,8 +288,8 @@ public function checkListByIds() /** * Get a list of exercises based on given ids. * @POST - * @Param(type="post", name="ids", validation="array", description="Identifications of exercises") */ + #[Post("ids", new VArray(), "Identifications of exercises")] public function actionListByIds() { $exercises = $this->exercises->findByIds($this->getRequest()->getPost("ids")); @@ -283,8 +314,8 @@ public function checkDetail(string $id) /** * Get details of an exercise * @GET - * @param string $id identification of exercise */ + #[Path("id", new VString(), "identification of exercise", required: true)] public function actionDetail(string $id) { /** @var Exercise $exercise */ @@ -304,29 +335,42 @@ public function checkUpdateDetail(string $id) /** * Update detail of an exercise * @POST - * @param string $id identification of exercise * @throws BadRequestException * @throws ForbiddenRequestException * @throws InvalidArgumentException - * @Param(type="post", name="version", validation="numericint", description="Version of the edited exercise") - * @Param(type="post", name="difficulty", - * description="Difficulty of an exercise, should be one of 'easy', 'medium' or 'hard'") - * @Param(type="post", name="localizedTexts", validation="array", description="A description of the exercise") - * @Param(type="post", name="isPublic", validation="bool", required=false, - * description="Exercise can be public or private") - * @Param(type="post", name="isLocked", validation="bool", required=false, - * description="If true, the exercise cannot be assigned") - * @Param(type="post", name="configurationType", validation="string", required=false, - * description="Identifies the way the evaluation of the exercise is configured") - * @Param(type="post", name="solutionFilesLimit", validation="numericint|null", - * description="Maximal number of files in a solution being submitted (default for assignments)") - * @Param(type="post", name="solutionSizeLimit", validation="numericint|null", - * description="Maximal size (bytes) of all files in a solution being submitted (default for assignments)") - * @Param(type="post", name="mergeJudgeLogs", validation="bool", - * description="If true, judge stderr will be merged into stdout (default for assignments)") * @throws BadRequestException * @throws InvalidArgumentException */ + #[Post("version", new VInt(), "Version of the edited exercise")] + #[Post( + "difficulty", + new VMixed(), + "Difficulty of an exercise, should be one of 'easy', 'medium' or 'hard'", + nullable: true, + )] + #[Post("localizedTexts", new VArray(), "A description of the exercise")] + #[Post("isPublic", new VBool(), "Exercise can be public or private", required: false)] + #[Post("isLocked", new VBool(), "If true, the exercise cannot be assigned", required: false)] + #[Post( + "configurationType", + new VString(), + "Identifies the way the evaluation of the exercise is configured", + required: false, + )] + #[Post( + "solutionFilesLimit", + new VInt(), + "Maximal number of files in a solution being submitted (default for assignments)", + nullable: true, + )] + #[Post( + "solutionSizeLimit", + new VInt(), + "Maximal size (bytes) of all files in a solution being submitted (default for assignments)", + nullable: true, + )] + #[Post("mergeJudgeLogs", new VBool(), "If true, judge stderr will be merged into stdout (default for assignments)")] + #[Path("id", new VString(), "identification of exercise", required: true)] public function actionUpdateDetail(string $id) { $req = $this->getRequest(); @@ -441,9 +485,9 @@ public function checkValidate($id) /** * Check if the version of the exercise is up-to-date. * @POST - * @Param(type="post", name="version", validation="numericint", description="Version of the exercise.") - * @param string $id Identifier of the exercise */ + #[Post("version", new VInt(), "Version of the exercise.")] + #[Path("id", new VString(), "Identifier of the exercise", required: true)] public function actionValidate($id) { $exercise = $this->exercises->findOrThrow($id); @@ -470,10 +514,10 @@ public function checkAssignments(string $id) /** * Get all non-archived assignments created from this exercise. * @GET - * @param string $id Identifier of the exercise - * @param bool $archived Include also archived groups in the result * @throws NotFoundException */ + #[Path("id", new VString(), "Identifier of the exercise", required: true)] + #[Query("archived", new VBool(), "Include also archived groups in the result", required: false)] public function actionAssignments(string $id, bool $archived = false) { $exercise = $this->exercises->findOrThrow($id); @@ -491,12 +535,12 @@ function (Assignment $assignment) use ($archived) { * Create exercise with all default values. * Exercise detail can be then changed in appropriate endpoint. * @POST - * @Param(type="post", name="groupId", description="Identifier of the group to which exercise belongs to") * @throws ForbiddenRequestException * @throws NotFoundException * @throws ApiException * @throws ParseException */ + #[Post("groupId", new VMixed(), "Identifier of the group to which exercise belongs to", nullable: true)] public function actionCreate() { $user = $this->getCurrentUser(); @@ -545,12 +589,11 @@ public function checkHardwareGroups(string $id) /** * Set hardware groups which are associated with exercise. * @POST - * @param string $id identifier of exercise - * @Param(type="post", name="hwGroups", validation="array", - * description="List of hardware groups identifications to which exercise belongs to") * @throws ForbiddenRequestException * @throws NotFoundException */ + #[Post("hwGroups", new VArray(), "List of hardware groups identifications to which exercise belongs to")] + #[Path("id", new VString(), "identifier of exercise", required: true)] public function actionHardwareGroups(string $id) { $exercise = $this->exercises->findOrThrow($id); @@ -593,8 +636,8 @@ public function checkRemove(string $id) /** * Delete an exercise * @DELETE - * @param string $id */ + #[Path("id", new VString(), required: true)] public function actionRemove(string $id) { /** @var Exercise $exercise */ @@ -607,13 +650,13 @@ public function actionRemove(string $id) /** * Fork exercise from given one into the completely new one. * @POST - * @param string $id Identifier of the exercise - * @Param(type="post", name="groupId", description="Identifier of the group to which exercise will be forked") * @throws ApiException * @throws ForbiddenRequestException * @throws NotFoundException * @throws ParseException */ + #[Post("groupId", new VMixed(), "Identifier of the group to which exercise will be forked", nullable: true)] + #[Path("id", new VString(), "Identifier of the exercise", required: true)] public function actionForkFrom(string $id) { $user = $this->getCurrentUser(); @@ -652,10 +695,10 @@ public function checkAttachGroup(string $id, string $groupId) /** * Attach exercise to group with given identification. * @POST - * @param string $id Identifier of the exercise - * @param string $groupId Identifier of the group to which exercise should be attached * @throws InvalidArgumentException */ + #[Path("id", new VString(), "Identifier of the exercise", required: true)] + #[Path("groupId", new VString(), "Identifier of the group to which exercise should be attached", required: true)] public function actionAttachGroup(string $id, string $groupId) { $exercise = $this->exercises->findOrThrow($id); @@ -686,10 +729,10 @@ public function checkDetachGroup(string $id, string $groupId) /** * Detach exercise from given group. * @DELETE - * @param string $id Identifier of the exercise - * @param string $groupId Identifier of the group which should be detached from exercise * @throws InvalidArgumentException */ + #[Path("id", new VString(), "Identifier of the exercise", required: true)] + #[Path("groupId", new VString(), "Identifier of the group which should be detached from exercise", required: true)] public function actionDetachGroup(string $id, string $groupId) { $exercise = $this->exercises->findOrThrow($id); @@ -753,13 +796,15 @@ public function checkTagsUpdateGlobal(string $tag) * Update the tag globally. At the moment, the only 'update' function is 'rename'. * Other types of updates may be implemented in the future. * @POST - * @param string $tag Tag to be updated - * @Param(type="query", name="renameTo", validation="string:1..32", required=false, - * description="New name of the tag") - * @Param(type="query", name="force", validation="bool", required=false, - * description="If true, the rename will be allowed even if the new tag name exists (tags will be merged). - * Otherwise, name collisions will result in error.") */ + #[Query("renameTo", new VString(1, 32), "New name of the tag", required: false)] + #[Query( + "force", + new VBool(), + "If true, the rename will be allowed even if the new tag name exists (tags will be merged). Otherwise, name collisions will result in error.", + required: false, + )] + #[Path("tag", new VString(), "Tag to be updated", required: true)] public function actionTagsUpdateGlobal(string $tag, string $renameTo = null, bool $force = false) { // Check whether at least one modification action is present (so far, we have only renameTo) @@ -799,8 +844,8 @@ public function checkTagsRemoveGlobal(string $tag) /** * Remove a tag from all exercises. * @POST - * @param string $tag Tag to be removed */ + #[Path("tag", new VString(), "Tag to be removed", required: true)] public function actionTagsRemoveGlobal(string $tag) { $removeCount = $this->exerciseTags->removeTag($tag); @@ -823,15 +868,13 @@ public function checkAddTag(string $id) /** * Add tag with given name to the exercise. * @POST - * @param string $id - * @param string $name - * @Param(type="query", name="name", validation="string:1..32", - * description="Name of the newly added tag to given exercise") * @throws BadRequestException * @throws NotFoundException * @throws ForbiddenRequestException * @throws InvalidArgumentException */ + #[Path("id", new VString(), required: true)] + #[Path("name", new VString(1, 32), "Name of the newly added tag to given exercise", required: true)] public function actionAddTag(string $id, string $name) { if (!$this->exerciseTags->verifyTagName($name)) { @@ -861,10 +904,10 @@ public function checkRemoveTag(string $id) /** * Remove tag with given name from exercise. * @DELETE - * @param string $id - * @param string $name * @throws NotFoundException */ + #[Path("id", new VString(), required: true)] + #[Path("name", new VString(), required: true)] public function actionRemoveTag(string $id, string $name) { $exercise = $this->exercises->findOrThrow($id); @@ -889,11 +932,10 @@ public function checkSetArchived(string $id) /** * (Un)mark the exercise as archived. Nothing happens if the exercise is already in the requested state. * @POST - * @param string $id identifier of the exercise - * @Param(type="post", name="archived", required=true, validation=boolean, - * description="Whether the exercise should be marked or unmarked") * @throws NotFoundException */ + #[Post("archived", new VBool(), "Whether the exercise should be marked or unmarked", required: true)] + #[Path("id", new VString(), "identifier of the exercise", required: true)] public function actionSetArchived(string $id) { $exercise = $this->exercises->findOrThrow($id); @@ -919,12 +961,11 @@ public function checkSetAuthor(string $id) /** * Change the author of the exercise. This is a very special operation reserved for powerful users. * @POST - * @param string $id identifier of the exercise - * @Param(type="post", name="author", required=true, validation="string:36", - * description="Id of the new author of the exercise.") * @throws NotFoundException * @throws ForbiddenRequestException */ + #[Post("author", new VUuid(), "Id of the new author of the exercise.", required: true)] + #[Path("id", new VString(), "identifier of the exercise", required: true)] public function actionSetAuthor(string $id) { $exercise = $this->exercises->findOrThrow($id); @@ -958,10 +999,10 @@ public function checkSetAdmins(string $id) * Change who the admins of an exercise are (all users on the list are added, * prior admins not on the list are removed). * @POST - * @param string $id identifier of the exercise - * @Param(type="post", name="admins", required=true, validation=array, description="List of user IDs.") * @throws NotFoundException */ + #[Post("admins", new VArray(), "List of user IDs.", required: true)] + #[Path("id", new VString(), "identifier of the exercise", required: true)] public function actionSetAdmins(string $id) { $exercise = $this->exercises->findOrThrow($id); @@ -1018,9 +1059,9 @@ public function checkSendNotification(string $id) * or the exercise is modified significantly. * The response is number of emails sent (number of notified users). * @POST - * @param string $id identifier of the exercise - * @Param(type="post", name="message", validation=string, description="Message sent to notified users.") */ + #[Post("message", new VString(), "Message sent to notified users.")] + #[Path("id", new VString(), "identifier of the exercise", required: true)] public function actionSendNotification(string $id) { $exercise = $this->exercises->findOrThrow($id); diff --git a/app/V1Module/presenters/ExtensionsPresenter.php b/app/V1Module/presenters/ExtensionsPresenter.php index 72ccd3c1f..727f7caa8 100644 --- a/app/V1Module/presenters/ExtensionsPresenter.php +++ b/app/V1Module/presenters/ExtensionsPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\ForbiddenRequestException; use App\Exceptions\BadRequestException; use App\Model\Repository\Instances; @@ -59,9 +72,11 @@ public function checkUrl(string $extId, string $instanceId) /** * Return URL refering to the extension with properly injected temporary JWT token. * @GET - * @Param(type="query", name="locale", required=false, validation="string:2") - * @Param(type="query", name="return", required=false, validation="string") */ + #[Query("locale", new VString(2, 2), required: false)] + #[Query("return", new VString(), required: false)] + #[Path("extId", new VString(), required: true)] + #[Path("instanceId", new VString(), required: true)] public function actionUrl(string $extId, string $instanceId, ?string $locale, ?string $return) { $user = $this->getCurrentUser(); @@ -117,6 +132,7 @@ public function checkToken(string $extId) * (from a temp token passed via URL). It also returns details about authenticated user. * @POST */ + #[Path("extId", new VString(), required: true)] public function actionToken(string $extId) { $user = $this->getCurrentUser(); diff --git a/app/V1Module/presenters/ForgottenPasswordPresenter.php b/app/V1Module/presenters/ForgottenPasswordPresenter.php index 0a7462670..51a8fbd8c 100644 --- a/app/V1Module/presenters/ForgottenPasswordPresenter.php +++ b/app/V1Module/presenters/ForgottenPasswordPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\ForbiddenRequestException; use App\Exceptions\NotFoundException; use App\Helpers\ForgottenPasswordHelper; @@ -47,10 +60,9 @@ class ForgottenPasswordPresenter extends BasePresenter /** * Request a password reset (user will receive an e-mail that prompts them to reset their password) * @POST - * @Param(type="post", name="username", validation="string:2..", - * description="An identifier of the user whose password should be reset") * @throws NotFoundException */ + #[Post("username", new VString(2), "An identifier of the user whose password should be reset")] public function actionDefault() { $req = $this->getHttpRequest(); @@ -66,10 +78,10 @@ public function actionDefault() /** * Change the user's password * @POST - * @Param(type="post", name="password", validation="string:2..", description="The new password") * @LoggedIn * @throws ForbiddenRequestException */ + #[Post("password", new VString(2), "The new password")] public function actionChange() { if (!$this->isInScope(TokenScope::CHANGE_PASSWORD)) { @@ -98,8 +110,8 @@ public function actionChange() /** * Check if a password is strong enough * @POST - * @Param(type="post", name="password", description="The password to be checked") */ + #[Post("password", new VMixed(), "The password to be checked", nullable: true)] public function actionValidatePasswordStrength() { $password = $this->getRequest()->getPost("password"); diff --git a/app/V1Module/presenters/GroupExternalAttributesPresenter.php b/app/V1Module/presenters/GroupExternalAttributesPresenter.php index f2684fa59..809106c36 100644 --- a/app/V1Module/presenters/GroupExternalAttributesPresenter.php +++ b/app/V1Module/presenters/GroupExternalAttributesPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\ForbiddenRequestException; use App\Exceptions\BadRequestException; use App\Model\Repository\GroupExternalAttributes; @@ -52,8 +65,6 @@ public function checkDefault() /** * Return all attributes that correspond to given filtering parameters. * @GET - * @Param(type="query", name="filter", required=true, validation="string", - * description="JSON-encoded filter query in DNF as [clause OR clause...]") * * The filter is encocded as array of objects (logically represented as disjunction of clauses) * -- i.e., [clause1 OR clause2 ...]. Each clause is an object with the following keys: @@ -64,6 +75,7 @@ public function checkDefault() * * The endpoint will return a list of matching attributes and all related group entities. */ + #[Query("filter", new VString(), "JSON-encoded filter query in DNF as [clause OR clause...]", required: true)] public function actionDefault(?string $filter) { $filterStruct = json_decode($filter ?? '', true); @@ -99,14 +111,12 @@ public function checkAdd() /** * Create an external attribute for given group. - * @Param(type="post", name="service", required=true, validation="string:1..32", - * description="Identifier of the external service creating the attribute") - * @Param(type="post", name="key", required=true, validation="string:1..32", - * description="Key of the attribute (must be valid identifier)") - * @Param(type="post", name="value", required=true, validation="string:0..255", - * description="Value of the attribute (arbitrary string)") * @POST */ + #[Post("service", new VString(1, 32), "Identifier of the external service creating the attribute", required: true)] + #[Post("key", new VString(1, 32), "Key of the attribute (must be valid identifier)", required: true)] + #[Post("value", new VString(0, 255), "Value of the attribute (arbitrary string)", required: true)] + #[Path("groupId", new VString(), required: true)] public function actionAdd(string $groupId) { $group = $this->groups->findOrThrow($groupId); @@ -132,6 +142,7 @@ public function checkRemove() * Remove selected attribute * @DELETE */ + #[Path("id", new VString(), required: true)] public function actionRemove(string $id) { $attribute = $this->groupExternalAttributes->findOrThrow($id); diff --git a/app/V1Module/presenters/GroupInvitationsPresenter.php b/app/V1Module/presenters/GroupInvitationsPresenter.php index f8ec13fbe..d537233ce 100644 --- a/app/V1Module/presenters/GroupInvitationsPresenter.php +++ b/app/V1Module/presenters/GroupInvitationsPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\ForbiddenRequestException; use App\Model\Repository\GroupInvitations; use App\Model\Repository\Groups; @@ -51,6 +64,7 @@ public function checkDefault($id) * Return invitation details including all relevant group entities (so a name can be constructed). * @GET */ + #[Path("id", new VString(), required: true)] public function actionDefault($id) { $invitation = $this->groupInvitations->findOrThrow($id); @@ -72,9 +86,10 @@ public function checkUpdate($id) /** * Edit the invitation. * @POST - * @Param(name="expireAt", type="post", validation="timestamp|null", description="When the invitation expires.") - * @Param(name="note", type="post", description="Note for the students who wish to use the invitation link.") */ + #[Post("expireAt", new VTimestamp(), "When the invitation expires.", nullable: true)] + #[Post("note", new VMixed(), "Note for the students who wish to use the invitation link.", nullable: true)] + #[Path("id", new VString(), required: true)] public function actionUpdate($id) { $req = $this->getRequest(); @@ -97,6 +112,7 @@ public function checkRemove($id) /** * @DELETE */ + #[Path("id", new VString(), required: true)] public function actionRemove($id) { $invitation = $this->groupInvitations->findOrThrow($id); @@ -121,6 +137,7 @@ public function checkAccept($id) * Allow the current user to join the corresponding group using the invitation. * @POST */ + #[Path("id", new VString(), required: true)] public function actionAccept($id) { $invitation = $this->groupInvitations->findOrThrow($id); @@ -145,6 +162,7 @@ public function checkList($groupId) * List all invitations of a group. * @GET */ + #[Path("groupId", new VString(), required: true)] public function actionList($groupId) { $group = $this->groups->findOrThrow($groupId); @@ -162,9 +180,10 @@ public function checkCreate($groupId) /** * Create a new invitation for given group. * @POST - * @Param(name="expireAt", type="post", validation="timestamp|null", description="When the invitation expires.") - * @Param(name="note", type="post", description="Note for the students who wish to use the invitation link.") */ + #[Post("expireAt", new VTimestamp(), "When the invitation expires.", nullable: true)] + #[Post("note", new VMixed(), "Note for the students who wish to use the invitation link.", nullable: true)] + #[Path("groupId", new VString(), required: true)] public function actionCreate($groupId) { $req = $this->getRequest(); diff --git a/app/V1Module/presenters/GroupsPresenter.php b/app/V1Module/presenters/GroupsPresenter.php index accca0407..64aad84f6 100644 --- a/app/V1Module/presenters/GroupsPresenter.php +++ b/app/V1Module/presenters/GroupsPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\InvalidArgumentException; use App\Exceptions\NotFoundException; use App\Exceptions\BadRequestException; @@ -170,14 +183,28 @@ class GroupsPresenter extends BasePresenter /** * Get a list of all non-archived groups a user can see. The return set is filtered by parameters. * @GET - * @param string|null $instanceId Only groups of this instance are returned. - * @param bool $ancestors If true, returns an ancestral closure of the initial result set. - * Included ancestral groups do not respect other filters (archived, search, ...). - * @param string|null $search Search string. Only groups containing this string as - * a substring of their names are returned. - * @param bool $archived Include also archived groups in the result. - * @param bool $onlyArchived Automatically implies $archived flag and returns only archived groups. */ + #[Query("instanceId", new VString(), "Only groups of this instance are returned.", required: false, nullable: true)] + #[Query( + "ancestors", + new VBool(), + "If true, returns an ancestral closure of the initial result set. Included ancestral groups do not respect other filters (archived, search, ...).", + required: false, + )] + #[Query( + "search", + new VString(), + "Search string. Only groups containing this string as a substring of their names are returned.", + required: false, + nullable: true, + )] + #[Query("archived", new VBool(), "Include also archived groups in the result.", required: false)] + #[Query( + "onlyArchived", + new VBool(), + "Automatically implies \$archived flag and returns only archived groups.", + required: false, + )] public function actionDefault( string $instanceId = null, bool $ancestors = false, @@ -226,33 +253,42 @@ private function setGroupPoints(Request $req, Group $group): void /** * Create a new group * @POST - * @Param(type="post", name="instanceId", validation="string:36", - * description="An identifier of the instance where the group should be created") - * @Param(type="post", name="externalId", required=false, - * description="An informative, human readable identifier of the group") - * @Param(type="post", name="parentGroupId", validation="string:36", required=false, - * description="Identifier of the parent group (if none is given, a top-level group is created)") - * @Param(type="post", name="publicStats", validation="bool", required=false, - * description="Should students be able to see each other's results?") - * @Param(type="post", name="detaining", validation="bool", required=false, - * description="Are students prevented from leaving the group on their own?") - * @Param(type="post", name="isPublic", validation="bool", required=false, - * description="Should the group be visible to all student?") - * @Param(type="post", name="isOrganizational", validation="bool", required=false, - * description="Whether the group is organizational (no assignments nor students).") - * @Param(type="post", name="isExam", validation="bool", required=false, - * description="Whether the group is an exam group.") - * @Param(type="post", name="localizedTexts", validation="array", required=false, - * description="Localized names and descriptions") - * @Param(type="post", name="threshold", validation="numericint", required=false, - * description="A minimum percentage of points needed to pass the course") - * @Param(type="post", name="pointsLimit", validation="numericint", required=false, - * description="A minimum of (absolute) points needed to pass the course") - * @Param(type="post", name="noAdmin", validation="bool", required=false, - * description="If true, no admin is assigned to group (current user is assigned as admin by default.") * @throws ForbiddenRequestException * @throws InvalidArgumentException */ + #[Post("instanceId", new VUuid(), "An identifier of the instance where the group should be created")] + #[Post( + "externalId", + new VMixed(), + "An informative, human readable identifier of the group", + required: false, + nullable: true, + )] + #[Post( + "parentGroupId", + new VUuid(), + "Identifier of the parent group (if none is given, a top-level group is created)", + required: false, + )] + #[Post("publicStats", new VBool(), "Should students be able to see each other's results?", required: false)] + #[Post("detaining", new VBool(), "Are students prevented from leaving the group on their own?", required: false)] + #[Post("isPublic", new VBool(), "Should the group be visible to all student?", required: false)] + #[Post( + "isOrganizational", + new VBool(), + "Whether the group is organizational (no assignments nor students).", + required: false, + )] + #[Post("isExam", new VBool(), "Whether the group is an exam group.", required: false)] + #[Post("localizedTexts", new VArray(), "Localized names and descriptions", required: false)] + #[Post("threshold", new VInt(), "A minimum percentage of points needed to pass the course", required: false)] + #[Post("pointsLimit", new VInt(), "A minimum of (absolute) points needed to pass the course", required: false)] + #[Post( + "noAdmin", + new VBool(), + "If true, no admin is assigned to group (current user is assigned as admin by default.", + required: false, + )] public function actionAddGroup() { $req = $this->getRequest(); @@ -308,12 +344,12 @@ public function actionAddGroup() /** * Validate group creation data * @POST - * @Param(name="name", type="post", description="Name of the group") - * @Param(name="locale", type="post", description="The locale of the name") - * @Param(name="instanceId", type="post", description="Identifier of the instance where the group belongs") - * @Param(name="parentGroupId", type="post", required=false, description="Identifier of the parent group") * @throws ForbiddenRequestException */ + #[Post("name", new VMixed(), "Name of the group", nullable: true)] + #[Post("locale", new VMixed(), "The locale of the name", nullable: true)] + #[Post("instanceId", new VMixed(), "Identifier of the instance where the group belongs", nullable: true)] + #[Post("parentGroupId", new VMixed(), "Identifier of the parent group", required: false, nullable: true)] public function actionValidateAddGroupData() { $req = $this->getRequest(); @@ -345,22 +381,22 @@ public function checkUpdateGroup(string $id) /** * Update group info * @POST - * @Param(type="post", name="externalId", required=false, - * description="An informative, human readable indentifier of the group") - * @Param(type="post", name="publicStats", validation="bool", - * description="Should students be able to see each other's results?") - * @Param(type="post", name="detaining", validation="bool", - * required=false, description="Are students prevented from leaving the group on their own?") - * @Param(type="post", name="isPublic", validation="bool", - * description="Should the group be visible to all student?") - * @Param(type="post", name="threshold", validation="numericint", required=false, - * description="A minimum percentage of points needed to pass the course") - * @Param(type="post", name="pointsLimit", validation="numericint", required=false, - * description="A minimum of (absolute) points needed to pass the course") - * @Param(type="post", name="localizedTexts", validation="array", description="Localized names and descriptions") - * @param string $id An identifier of the updated group * @throws InvalidArgumentException */ + #[Post( + "externalId", + new VMixed(), + "An informative, human readable indentifier of the group", + required: false, + nullable: true, + )] + #[Post("publicStats", new VBool(), "Should students be able to see each other's results?")] + #[Post("detaining", new VBool(), "Are students prevented from leaving the group on their own?", required: false)] + #[Post("isPublic", new VBool(), "Should the group be visible to all student?")] + #[Post("threshold", new VInt(), "A minimum percentage of points needed to pass the course", required: false)] + #[Post("pointsLimit", new VInt(), "A minimum of (absolute) points needed to pass the course", required: false)] + #[Post("localizedTexts", new VArray(), "Localized names and descriptions")] + #[Path("id", new VString(), "An identifier of the updated group", required: true)] public function actionUpdateGroup(string $id) { $req = $this->getRequest(); @@ -397,11 +433,11 @@ public function checkSetOrganizational(string $id) /** * Set the 'isOrganizational' flag for a group * @POST - * @Param(type="post", name="value", validation="bool", required=true, description="The value of the flag") - * @param string $id An identifier of the updated group * @throws BadRequestException * @throws NotFoundException */ + #[Post("value", new VBool(), "The value of the flag", required: true)] + #[Path("id", new VString(), "An identifier of the updated group", required: true)] public function actionSetOrganizational(string $id) { $group = $this->groups->findOrThrow($id); @@ -434,10 +470,10 @@ public function checkSetArchived(string $id) /** * Set the 'isArchived' flag for a group * @POST - * @Param(type="post", name="value", validation="bool", required=true, description="The value of the flag") - * @param string $id An identifier of the updated group * @throws NotFoundException */ + #[Post("value", new VBool(), "The value of the flag", required: true)] + #[Path("id", new VString(), "An identifier of the updated group", required: true)] public function actionSetArchived(string $id) { $group = $this->groups->findOrThrow($id); @@ -515,11 +551,11 @@ public function checkSetExam(string $id) * Change the group "exam" indicator. If denotes that the group should be listed in exam groups instead of * regular groups and the assignments should have "isExam" flag set by default. * @POST - * @Param(type="post", name="value", validation="bool", required=true, description="The value of the flag") - * @param string $id An identifier of the updated group * @throws BadRequestException * @throws NotFoundException */ + #[Post("value", new VBool(), "The value of the flag", required: true)] + #[Path("id", new VString(), "An identifier of the updated group", required: true)] public function actionSetExam(string $id) { $group = $this->groups->findOrThrow($id); @@ -543,15 +579,23 @@ public function checkSetExamPeriod(string $id) * This endpoint is also used to update already planned exam period, but only dates in the future * can be editted (e.g., once an exam begins, the beginning may no longer be updated). * @POST - * @Param(type="post", name="begin", validation="timestamp|null", required=false, - * description="When the exam begins (unix ts in the future, optional if update is performed).") - * @Param(type="post", name="end", validation="timestamp", required=true, - * description="When the exam ends (unix ts in the future, no more than a day after 'begin').") - * @Param(type="post", name="strict", validation="bool", required=false, - * description="Whether locked users are prevented from accessing other groups.") - * @param string $id An identifier of the updated group * @throws NotFoundException */ + #[Post( + "begin", + new VTimestamp(), + "When the exam begins (unix ts in the future, optional if update is performed).", + required: false, + nullable: true, + )] + #[Post( + "end", + new VTimestamp(), + "When the exam ends (unix ts in the future, no more than a day after 'begin').", + required: true, + )] + #[Post("strict", new VBool(), "Whether locked users are prevented from accessing other groups.", required: false)] + #[Path("id", new VString(), "An identifier of the updated group", required: true)] public function actionSetExamPeriod(string $id) { $group = $this->groups->findOrThrow($id); @@ -662,9 +706,9 @@ public function checkRemoveExamPeriod(string $id) /** * Change the group back to regular group (remove information about an exam). * @DELETE - * @param string $id An identifier of the updated group * @throws NotFoundException */ + #[Path("id", new VString(), "An identifier of the updated group", required: true)] public function actionRemoveExamPeriod(string $id) { $group = $this->groups->findOrThrow($id); @@ -712,9 +756,9 @@ public function checkGetExamLocks(string $id, string $examId) /** * Retrieve a list of locks for given exam * @GET - * @param string $id An identifier of the related group - * @param string $examId An identifier of the exam */ + #[Path("id", new VString(), "An identifier of the related group", required: true)] + #[Path("examId", new VString(), "An identifier of the exam", required: true)] public function actionGetExamLocks(string $id, string $examId) { $group = $this->groups->findOrThrow($id); @@ -726,11 +770,11 @@ public function actionGetExamLocks(string $id, string $examId) /** * Relocate the group under a different parent. * @POST - * @param string $id An identifier of the relocated group - * @param string $newParentId An identifier of the new parent group * @throws NotFoundException * @throws BadRequestException */ + #[Path("id", new VString(), "An identifier of the relocated group", required: true)] + #[Path("newParentId", new VString(), "An identifier of the new parent group", required: true)] public function actionRelocate(string $id, string $newParentId) { $group = $this->groups->findOrThrow($id); @@ -786,8 +830,8 @@ public function checkRemoveGroup(string $id) /** * Delete a group * @DELETE - * @param string $id Identifier of the group */ + #[Path("id", new VString(), "Identifier of the group", required: true)] public function actionRemoveGroup(string $id) { $group = $this->groups->findOrThrow($id); @@ -809,8 +853,8 @@ public function checkDetail(string $id) /** * Get details of a group * @GET - * @param string $id Identifier of the group */ + #[Path("id", new VString(), "Identifier of the group", required: true)] public function actionDetail(string $id) { $group = $this->groups->findOrThrow($id); @@ -830,9 +874,9 @@ public function checkSubgroups(string $id) /** * Get a list of subgroups of a group * @GET - * @param string $id Identifier of the group * @DEPRECTATED Subgroup list is part of group view. */ + #[Path("id", new VString(), "Identifier of the group", required: true)] public function actionSubgroups(string $id) { /** @var Group $group */ @@ -861,9 +905,9 @@ public function checkMembers(string $id) /** * Get a list of members of a group * @GET - * @param string $id Identifier of the group * @DEPRECATED Members are listed in group view. */ + #[Path("id", new VString(), "Identifier of the group", required: true)] public function actionMembers(string $id) { $group = $this->groups->findOrThrow($id); @@ -895,11 +939,10 @@ public function checkAddMember(string $id, string $userId) /** * Add/update a membership (other than student) for given user * @POST - * @Param(type="post", name="type", validation="string:1..", required=true, - * description="Identifier of membership type (admin, supervisor, ...)") - * @param string $id Identifier of the group - * @param string $userId Identifier of the supervisor */ + #[Post("type", new VString(1), "Identifier of membership type (admin, supervisor, ...)", required: true)] + #[Path("id", new VString(), "Identifier of the group", required: true)] + #[Path("userId", new VString(), "Identifier of the supervisor", required: true)] public function actionAddMember(string $id, string $userId) { $user = $this->users->findOrThrow($userId); @@ -942,9 +985,9 @@ public function checkRemoveMember(string $id, string $userId) /** * Remove a member (other than student) from a group * @DELETE - * @param string $id Identifier of the group - * @param string $userId Identifier of the supervisor */ + #[Path("id", new VString(), "Identifier of the group", required: true)] + #[Path("userId", new VString(), "Identifier of the supervisor", required: true)] public function actionRemoveMember(string $id, string $userId) { $user = $this->users->findOrThrow($userId); @@ -980,8 +1023,8 @@ public function checkAssignments(string $id) /** * Get all exercise assignments for a group * @GET - * @param string $id Identifier of the group */ + #[Path("id", new VString(), "Identifier of the group", required: true)] public function actionAssignments(string $id) { /** @var Group $group */ @@ -1014,8 +1057,8 @@ public function checkShadowAssignments(string $id) /** * Get all shadow assignments for a group * @GET - * @param string $id Identifier of the group */ + #[Path("id", new VString(), "Identifier of the group", required: true)] public function actionShadowAssignments(string $id) { /** @var Group $group */ @@ -1052,9 +1095,9 @@ public function checkStats(string $id) * Get statistics of a group. If the user does not have the rights to view all of these, try to at least * return their statistics. * @GET - * @param string $id Identifier of the group * @throws ForbiddenRequestException */ + #[Path("id", new VString(), "Identifier of the group", required: true)] public function actionStats(string $id) { $group = $this->groups->findOrThrow($id); @@ -1081,10 +1124,10 @@ public function checkStudentsStats(string $id, string $userId) /** * Get statistics of a single student in a group * @GET - * @param string $id Identifier of the group - * @param string $userId Identifier of the student * @throws BadRequestException */ + #[Path("id", new VString(), "Identifier of the group", required: true)] + #[Path("userId", new VString(), "Identifier of the student", required: true)] public function actionStudentsStats(string $id, string $userId) { $user = $this->users->findOrThrow($userId); @@ -1111,10 +1154,10 @@ public function checkStudentsSolutions(string $id, string $userId) /** * Get all solutions of a single student from all assignments in a group * @GET - * @param string $id Identifier of the group - * @param string $userId Identifier of the student * @throws BadRequestException */ + #[Path("id", new VString(), "Identifier of the group", required: true)] + #[Path("userId", new VString(), "Identifier of the student", required: true)] public function actionStudentsSolutions(string $id, string $userId) { $user = $this->users->findOrThrow($userId); @@ -1159,9 +1202,9 @@ public function checkAddStudent(string $id, string $userId) /** * Add a student to a group * @POST - * @param string $id Identifier of the group - * @param string $userId Identifier of the student */ + #[Path("id", new VString(), "Identifier of the group", required: true)] + #[Path("userId", new VString(), "Identifier of the student", required: true)] public function actionAddStudent(string $id, string $userId) { $user = $this->users->findOrThrow($userId); @@ -1190,9 +1233,9 @@ public function checkRemoveStudent(string $id, string $userId) /** * Remove a student from a group * @DELETE - * @param string $id Identifier of the group - * @param string $userId Identifier of the student */ + #[Path("id", new VString(), "Identifier of the group", required: true)] + #[Path("userId", new VString(), "Identifier of the student", required: true)] public function actionRemoveStudent(string $id, string $userId) { $user = $this->users->findOrThrow($userId); @@ -1222,9 +1265,9 @@ public function checkLockStudent(string $id, string $userId) /** * Lock student in a group and with an IP from which the request was made. * @POST - * @param string $id Identifier of the group - * @param string $userId Identifier of the student */ + #[Path("id", new VString(), "Identifier of the group", required: true)] + #[Path("userId", new VString(), "Identifier of the student", required: true)] public function actionLockStudent(string $id, string $userId) { $user = $this->users->findOrThrow($userId); @@ -1262,9 +1305,9 @@ public function checkUnlockStudent(string $id, string $userId) /** * Unlock a student currently locked in a group. * @DELETE - * @param string $id Identifier of the group - * @param string $userId Identifier of the student */ + #[Path("id", new VString(), "Identifier of the group", required: true)] + #[Path("userId", new VString(), "Identifier of the student", required: true)] public function actionUnlockStudent(string $id, string $userId) { $user = $this->users->findOrThrow($userId); diff --git a/app/V1Module/presenters/HardwareGroupsPresenter.php b/app/V1Module/presenters/HardwareGroupsPresenter.php index 305f11804..a930499b3 100644 --- a/app/V1Module/presenters/HardwareGroupsPresenter.php +++ b/app/V1Module/presenters/HardwareGroupsPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\ForbiddenRequestException; use App\Model\Repository\HardwareGroups; use App\Security\ACL\IHardwareGroupPermissions; diff --git a/app/V1Module/presenters/InstancesPresenter.php b/app/V1Module/presenters/InstancesPresenter.php index 151c51587..17cc60b8d 100644 --- a/app/V1Module/presenters/InstancesPresenter.php +++ b/app/V1Module/presenters/InstancesPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\ForbiddenRequestException; use App\Exceptions\NotFoundException; use App\Model\Entity\LocalizedGroup; @@ -106,12 +119,11 @@ public function checkCreateInstance() /** * Create a new instance * @POST - * @Param(type="post", name="name", validation="string:2..", description="Name of the instance") - * @Param(type="post", name="description", required=false, description="Description of the instance") - * @Param(type="post", name="isOpen", validation="bool", - * description="Should the instance be open for registration?") * @throws ForbiddenRequestException */ + #[Post("name", new VString(2), "Name of the instance")] + #[Post("description", new VMixed(), "Description of the instance", required: false, nullable: true)] + #[Post("isOpen", new VBool(), "Should the instance be open for registration?")] public function actionCreateInstance() { $req = $this->getRequest(); @@ -144,10 +156,9 @@ public function checkUpdateInstance(string $id) /** * Update an instance * @POST - * @Param(type="post", name="isOpen", validation="bool", required=false, - * description="Should the instance be open for registration?") - * @param string $id An identifier of the updated instance */ + #[Post("isOpen", new VBool(), "Should the instance be open for registration?", required: false)] + #[Path("id", new VString(), "An identifier of the updated instance", required: true)] public function actionUpdateInstance(string $id) { $instance = $this->instances->findOrThrow($id); @@ -174,8 +185,8 @@ public function checkDeleteInstance(string $id) /** * Delete an instance * @DELETE - * @param string $id An identifier of the instance to be deleted */ + #[Path("id", new VString(), "An identifier of the instance to be deleted", required: true)] public function actionDeleteInstance(string $id) { $instance = $this->instances->findOrThrow($id); @@ -204,8 +215,8 @@ public function checkDetail(string $id) /** * Get details of an instance * @GET - * @param string $id An identifier of the instance */ + #[Path("id", new VString(), "An identifier of the instance", required: true)] public function actionDetail(string $id) { $instance = $this->instances->findOrThrow($id); @@ -223,8 +234,8 @@ public function checkLicences(string $id) /** * Get a list of licenses associated with an instance * @GET - * @param string $id An identifier of the instance */ + #[Path("id", new VString(), "An identifier of the instance", required: true)] public function actionLicences(string $id) { $instance = $this->instances->findOrThrow($id); @@ -242,10 +253,10 @@ public function checkCreateLicence(string $id) /** * Create a new license for an instance * @POST - * @Param(type="post", name="note", validation="string:2..", description="A note for users or administrators") - * @Param(type="post", name="validUntil", validation="timestamp", description="Expiration date of the license") - * @param string $id An identifier of the instance */ + #[Post("note", new VString(2), "A note for users or administrators")] + #[Post("validUntil", new VTimestamp(), "Expiration date of the license")] + #[Path("id", new VString(), "An identifier of the instance", required: true)] public function actionCreateLicence(string $id) { $instance = $this->instances->findOrThrow($id); @@ -269,15 +280,12 @@ public function checkUpdateLicence(string $licenceId) /** * Update an existing license for an instance * @POST - * @Param(type="post", name="note", validation="string:2..255", required=false, - * description="A note for users or administrators") - * @Param(type="post", name="validUntil", validation="string", required=false, - * description="Expiration date of the license") - * @Param(type="post", name="isValid", validation="bool", required=false, - * description="Administrator switch to toggle licence validity") - * @param string $licenceId Identifier of the licence * @throws NotFoundException */ + #[Post("note", new VString(2, 255), "A note for users or administrators", required: false)] + #[Post("validUntil", new VString(), "Expiration date of the license", required: false)] + #[Post("isValid", new VBool(), "Administrator switch to toggle licence validity", required: false)] + #[Path("licenceId", new VString(), "Identifier of the licence", required: true)] public function actionUpdateLicence(string $licenceId) { $licence = $this->licences->findOrThrow($licenceId); @@ -309,9 +317,9 @@ public function checkDeleteLicence(string $licenceId) /** * Remove existing license for an instance * @DELETE - * @param string $licenceId Identifier of the licence * @throws NotFoundException */ + #[Path("licenceId", new VString(), "Identifier of the licence", required: true)] public function actionDeleteLicence(string $licenceId) { $licence = $this->licences->findOrThrow($licenceId); diff --git a/app/V1Module/presenters/LoginPresenter.php b/app/V1Module/presenters/LoginPresenter.php index b243102bf..47e3c526a 100644 --- a/app/V1Module/presenters/LoginPresenter.php +++ b/app/V1Module/presenters/LoginPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\BadRequestException; use App\Exceptions\ForbiddenRequestException; use App\Exceptions\FrontendErrorMappings; @@ -107,13 +120,13 @@ private function sendAccessTokenResponse(User $user) /** * Log in using user credentials * @POST - * @Param(type="post", name="username", validation="email:1..", description="User's E-mail") - * @Param(type="post", name="password", validation="string:1..", description="Password") * @throws AuthenticationException * @throws ForbiddenRequestException * @throws InvalidAccessTokenException * @throws WrongCredentialsException */ + #[Post("username", new VEmail(), "User's E-mail")] + #[Post("password", new VString(1), "Password")] public function actionDefault() { $req = $this->getRequest(); @@ -134,14 +147,14 @@ public function actionDefault() /** * Log in using an external authentication service * @POST - * @Param(type="post", name="token", validation="string:1..", description="JWT external authentication token") - * @param string $authenticatorName Identifier of the external authenticator * @throws AuthenticationException * @throws ForbiddenRequestException * @throws InvalidAccessTokenException * @throws WrongCredentialsException * @throws BadRequestException */ + #[Post("token", new VString(1), "JWT external authentication token")] + #[Path("authenticatorName", new VString(), "Identifier of the external authenticator", required: true)] public function actionExternal($authenticatorName) { $req = $this->getRequest(); @@ -168,11 +181,11 @@ public function checkTakeOver($userId) * Takeover user account with specified user identification. * @POST * @LoggedIn - * @param string $userId * @throws AuthenticationException * @throws ForbiddenRequestException * @throws InvalidAccessTokenException */ + #[Path("userId", new VString(), required: true)] public function actionTakeOver($userId) { $user = $this->users->findOrThrow($userId); @@ -235,15 +248,13 @@ public function checkIssueRestrictedToken() * Issue a new access token with a restricted set of scopes * @POST * @LoggedIn - * @Param(type="post", name="effectiveRole", required=false, validation="string", - * description="Effective user role contained within issued token") - * @Param(type="post", name="scopes", validation="list", description="A list of requested scopes") - * @Param(type="post", required=false, name="expiration", validation="numericint", - * description="How long should the token be valid (in seconds)") * @throws BadRequestException * @throws ForbiddenRequestException * @throws InvalidArgumentException */ + #[Post("effectiveRole", new VString(), "Effective user role contained within issued token", required: false)] + #[Post("scopes", new VArray(), "A list of requested scopes")] + #[Post("expiration", new VInt(), "How long should the token be valid (in seconds)", required: false)] public function actionIssueRestrictedToken() { $request = $this->getRequest(); diff --git a/app/V1Module/presenters/NotificationsPresenter.php b/app/V1Module/presenters/NotificationsPresenter.php index 5be144f33..3f28aae06 100644 --- a/app/V1Module/presenters/NotificationsPresenter.php +++ b/app/V1Module/presenters/NotificationsPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\ForbiddenRequestException; use App\Exceptions\InvalidArgumentException; use App\Exceptions\NotFoundException; @@ -54,8 +67,8 @@ public function checkDefault() * returns only the ones from given groups (and their ancestors) and * global ones (without group). * @GET - * @param array $groupsIds identifications of groups */ + #[Query("groupsIds", new VArray(), "identifications of groups", required: false)] public function actionDefault(array $groupsIds) { $ancestralGroupsIds = $this->groups->groupsIdsAncestralClosure($groupsIds); @@ -103,21 +116,17 @@ public function checkCreate() /** * Create notification with given attributes - * @Param(type="post", name="groupsIds", validation="array", - * description="Identification of groups") - * @Param(type="post", name="visibleFrom", validation="timestamp", - * description="Date from which is notification visible") - * @Param(type="post", name="visibleTo", validation="timestamp", - * description="Date to which is notification visible") - * @Param(type="post", name="role", validation="string:1..", - * description="Users with this role and its children can see notification") - * @Param(type="post", name="type", validation="string", description="Type of the notification (custom)") - * @Param(type="post", name="localizedTexts", validation="array", description="Text of notification") * @POST * @throws NotFoundException * @throws ForbiddenRequestException * @throws InvalidArgumentException */ + #[Post("groupsIds", new VArray(), "Identification of groups")] + #[Post("visibleFrom", new VTimestamp(), "Date from which is notification visible")] + #[Post("visibleTo", new VTimestamp(), "Date to which is notification visible")] + #[Post("role", new VString(1), "Users with this role and its children can see notification")] + #[Post("type", new VString(), "Type of the notification (custom)")] + #[Post("localizedTexts", new VArray(), "Text of notification")] public function actionCreate() { $notification = new Notification($this->getCurrentUser()); @@ -220,20 +229,17 @@ public function checkUpdate(string $id) /** * Update notification * @POST - * @param string $id - * @Param(type="post", name="groupsIds", validation="array", description="Identification of groups") - * @Param(type="post", name="visibleFrom", validation="timestamp", - * description="Date from which is notification visible") - * @Param(type="post", name="visibleTo", validation="timestamp", - * description="Date to which is notification visible") - * @Param(type="post", name="role", validation="string:1..", - * description="Users with this role and its children can see notification") - * @Param(type="post", name="type", validation="string", description="Type of the notification (custom)") - * @Param(type="post", name="localizedTexts", validation="array", description="Text of notification") * @throws NotFoundException * @throws ForbiddenRequestException * @throws InvalidArgumentException */ + #[Post("groupsIds", new VArray(), "Identification of groups")] + #[Post("visibleFrom", new VTimestamp(), "Date from which is notification visible")] + #[Post("visibleTo", new VTimestamp(), "Date to which is notification visible")] + #[Post("role", new VString(1), "Users with this role and its children can see notification")] + #[Post("type", new VString(), "Type of the notification (custom)")] + #[Post("localizedTexts", new VArray(), "Text of notification")] + #[Path("id", new VString(), required: true)] public function actionUpdate(string $id) { $notification = $this->notifications->findOrThrow($id); @@ -253,9 +259,9 @@ public function checkRemove(string $id) /** * Delete a notification * @DELETE - * @param string $id * @throws NotFoundException */ + #[Path("id", new VString(), required: true)] public function actionRemove(string $id) { $notification = $this->notifications->findOrThrow($id); diff --git a/app/V1Module/presenters/PipelinesPresenter.php b/app/V1Module/presenters/PipelinesPresenter.php index 91f52acc3..c3d22bb0c 100644 --- a/app/V1Module/presenters/PipelinesPresenter.php +++ b/app/V1Module/presenters/PipelinesPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\BadRequestException; use App\Exceptions\ExerciseConfigException; use App\Exceptions\ForbiddenRequestException; @@ -141,12 +154,24 @@ public function checkDefault(string $search = null) * Get a list of pipelines with an optional filter, ordering, and pagination pruning. * The result conforms to pagination protocol. * @GET - * @param int $offset Index of the first result. - * @param int|null $limit Maximal number of results returned. - * @param string|null $orderBy Name of the column (column concept). The '!' prefix indicate descending order. - * @param array|null $filters Named filters that prune the result. - * @param string|null $locale Currently set locale (used to augment order by clause if necessary), */ + #[Query("offset", new VInt(), "Index of the first result.", required: false)] + #[Query("limit", new VInt(), "Maximal number of results returned.", required: false, nullable: true)] + #[Query( + "orderBy", + new VString(), + "Name of the column (column concept). The '!' prefix indicate descending order.", + required: false, + nullable: true, + )] + #[Query("filters", new VArray(), "Named filters that prune the result.", required: false, nullable: true)] + #[Query( + "locale", + new VString(), + "Currently set locale (used to augment order by clause if necessary),", + required: false, + nullable: true, + )] public function actionDefault( int $offset = 0, int $limit = null, @@ -177,11 +202,15 @@ function (Pipeline $pipeline) { /** * Create a brand new pipeline. * @POST - * @Param(type="post", name="global", validation="bool", required=false, - * description="Whether the pipeline is global (has no author, is used in generic runtimes)") * @throws ForbiddenRequestException * @throws NotFoundException */ + #[Post( + "global", + new VBool(), + "Whether the pipeline is global (has no author, is used in generic runtimes)", + required: false, + )] public function actionCreatePipeline() { $req = $this->getRequest(); @@ -205,12 +234,16 @@ public function actionCreatePipeline() /** * Create a complete copy of given pipeline. * @POST - * @param string $id identification of pipeline to be copied - * @Param(type="post", name="global", validation="bool", required=false, - * description="Whether the pipeline is global (has no author, is used in generic runtimes)") * @throws ForbiddenRequestException * @throws NotFoundException */ + #[Post( + "global", + new VBool(), + "Whether the pipeline is global (has no author, is used in generic runtimes)", + required: false, + )] + #[Path("id", new VString(), "identification of pipeline to be copied", required: true)] public function actionForkPipeline(string $id) { $req = $this->getRequest(); @@ -243,9 +276,9 @@ public function checkRemovePipeline(string $id) /** * Delete an pipeline * @DELETE - * @param string $id * @throws NotFoundException */ + #[Path("id", new VString(), required: true)] public function actionRemovePipeline(string $id) { $pipeline = $this->pipelines->findOrThrow($id); @@ -265,9 +298,9 @@ public function checkGetPipeline(string $id) /** * Get pipeline based on given identification. * @GET - * @param string $id Identifier of the pipeline * @throws NotFoundException */ + #[Path("id", new VString(), "Identifier of the pipeline", required: true)] public function actionGetPipeline(string $id) { /** @var Pipeline $pipeline */ @@ -287,20 +320,24 @@ public function checkUpdatePipeline(string $id) /** * Update pipeline with given data. * @POST - * @param string $id Identifier of the pipeline - * @Param(type="post", name="name", validation="string:2..", description="Name of the pipeline") - * @Param(type="post", name="version", validation="numericint", description="Version of the edited pipeline") - * @Param(type="post", name="description", description="Human readable description of pipeline") - * @Param(type="post", name="pipeline", description="Pipeline configuration", required=false) - * @Param(type="post", name="parameters", validation="array", description="A set of parameters", required=false) - * @Param(type="post", name="global", validation="bool", required=false, - * description="Whether the pipeline is global (has no author, is used in generic runtimes)") * @throws ForbiddenRequestException * @throws NotFoundException * @throws BadRequestException * @throws ExerciseConfigException * @throws InvalidArgumentException */ + #[Post("name", new VString(2), "Name of the pipeline")] + #[Post("version", new VInt(), "Version of the edited pipeline")] + #[Post("description", new VMixed(), "Human readable description of pipeline", nullable: true)] + #[Post("pipeline", new VMixed(), "Pipeline configuration", required: false, nullable: true)] + #[Post("parameters", new VArray(), "A set of parameters", required: false)] + #[Post( + "global", + new VBool(), + "Whether the pipeline is global (has no author, is used in generic runtimes)", + required: false, + )] + #[Path("id", new VString(), "Identifier of the pipeline", required: true)] public function actionUpdatePipeline(string $id) { /** @var Pipeline $pipeline */ @@ -375,11 +412,11 @@ public function checkUpdateRuntimeEnvironments(string $id) /** * Set runtime environments associated with given pipeline. - * @param string $id Identifier of the pipeline * @POST * @throws ForbiddenRequestException * @throws NotFoundException */ + #[Path("id", new VString(), "Identifier of the pipeline", required: true)] public function actionUpdateRuntimeEnvironments(string $id) { /** @var Pipeline $pipeline */ @@ -398,11 +435,11 @@ public function actionUpdateRuntimeEnvironments(string $id) /** * Check if the version of the pipeline is up-to-date. * @POST - * @Param(type="post", name="version", validation="numericint", description="Version of the pipeline.") - * @param string $id Identifier of the pipeline * @throws ForbiddenRequestException * @throws NotFoundException */ + #[Post("version", new VInt(), "Version of the pipeline.")] + #[Path("id", new VString(), "Identifier of the pipeline", required: true)] public function actionValidatePipeline(string $id) { $pipeline = $this->pipelines->findOrThrow($id); @@ -432,12 +469,12 @@ public function checkUploadSupplementaryFiles(string $id) /** * Associate supplementary files with a pipeline and upload them to remote file server * @POST - * @Param(type="post", name="files", description="Identifiers of supplementary files") - * @param string $id identification of pipeline * @throws ForbiddenRequestException * @throws SubmissionFailedException * @throws NotFoundException */ + #[Post("files", new VMixed(), "Identifiers of supplementary files", nullable: true)] + #[Path("id", new VString(), "identification of pipeline", required: true)] public function actionUploadSupplementaryFiles(string $id) { $pipeline = $this->pipelines->findOrThrow($id); @@ -488,9 +525,9 @@ public function checkGetSupplementaryFiles(string $id) /** * Get list of all supplementary files for a pipeline * @GET - * @param string $id identification of pipeline * @throws NotFoundException */ + #[Path("id", new VString(), "identification of pipeline", required: true)] public function actionGetSupplementaryFiles(string $id) { $pipeline = $this->pipelines->findOrThrow($id); @@ -508,10 +545,10 @@ public function checkDeleteSupplementaryFile(string $id, string $fileId) /** * Delete supplementary pipeline file with given id * @DELETE - * @param string $id identification of pipeline - * @param string $fileId identification of file * @throws NotFoundException */ + #[Path("id", new VString(), "identification of pipeline", required: true)] + #[Path("fileId", new VString(), "identification of file", required: true)] public function actionDeleteSupplementaryFile(string $id, string $fileId) { $pipeline = $this->pipelines->findOrThrow($id); @@ -538,9 +575,9 @@ public function checkGetPipelineExercises(string $id) * Get all exercises that use given pipeline. * Only bare minimum is retrieved for each exercise (localized name and author). * @GET - * @param string $id Identifier of the pipeline * @throws NotFoundException */ + #[Path("id", new VString(), "Identifier of the pipeline", required: true)] public function actionGetPipelineExercises(string $id) { $exercises = $this->exercises->getPipelineExercises($id); diff --git a/app/V1Module/presenters/PlagiarismPresenter.php b/app/V1Module/presenters/PlagiarismPresenter.php index 7b31d42cb..d8959c430 100644 --- a/app/V1Module/presenters/PlagiarismPresenter.php +++ b/app/V1Module/presenters/PlagiarismPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\BadRequestException; use App\Exceptions\ForbiddenRequestException; use App\Exceptions\ParseException; @@ -85,11 +98,19 @@ public function checkListBatches(?string $detectionTool, ?string $solutionId): v /** * Get a list of all batches, optionally filtered by query params. * @GET - * @Param(type="query", name="detectionTool", required=false, validation="string:1..255", - * description="Requests only batches created by a particular detection tool.") - * @Param(type="query", name="solutionId", required=false, validation="string:36", - * description="Requests only batches where particular solution has detected similarities.") */ + #[Query( + "detectionTool", + new VString(1, 255), + "Requests only batches created by a particular detection tool.", + required: false, + )] + #[Query( + "solutionId", + new VUuid(), + "Requests only batches where particular solution has detected similarities.", + required: false, + )] public function actionListBatches(?string $detectionTool, ?string $solutionId): void { $solution = $solutionId ? $this->assignmentSolutions->findOrThrow($solutionId) : null; @@ -108,6 +129,7 @@ public function checkBatchDetail(string $id): void * Fetch a detail of a particular batch record. * @GET */ + #[Path("id", new VString(), required: true)] public function actionBatchDetail(string $id): void { $batch = $this->detectionBatches->findOrThrow($id); @@ -124,11 +146,14 @@ public function checkCreateBatch(): void /** * Create new detection batch record * @POST - * @Param(type="post", name="detectionTool", validation="string:1..255", - * description="Identifier of the external tool used to detect similarities.") - * @Param(type="post", name="detectionToolParams", validation="string:0..255", required="false" - * description="Tool-specific parameters (e.g., CLI args) used for this particular batch.") */ + #[Post("detectionTool", new VString(1, 255), "Identifier of the external tool used to detect similarities.")] + #[Post( + "detectionToolParams", + new VString(0, 255), + "Tool-specific parameters (e.g., CLI args) used for this particular batch.", + required: false, + )] public function actionCreateBatch(): void { $req = $this->getRequest(); @@ -150,9 +175,9 @@ public function checkUpdateBatch(string $id): void /** * Update dectection bath record. At the momeny, only the uploadCompletedAt can be changed. * @POST - * @Param(type="post", name="uploadCompleted", validation="bool", - * description="Whether the upload of the batch data is completed or not.") */ + #[Post("uploadCompleted", new VBool(), "Whether the upload of the batch data is completed or not.")] + #[Path("id", new VString(), required: true)] public function actionUpdateBatch(string $id): void { $req = $this->getRequest(); @@ -176,6 +201,8 @@ public function checkGetSimilarities(string $id, string $solutionId): void * Returns a list of detected similarities entities (similar file records are nested within). * @GET */ + #[Path("id", new VString(), required: true)] + #[Path("solutionId", new VString(), required: true)] public function actionGetSimilarities(string $id, string $solutionId): void { $batch = $this->detectionBatches->findOrThrow($id); @@ -199,17 +226,19 @@ public function checkAddSimilarities(string $id, string $solutionId): void * Appends one detected similarity record (similarities associated with one file and one other author) * into a detected batch. This division was selected to make the appends relatively small and managable. * @POST - * @Param(type="post", name="solutionFileId", validation="string:36", - * description="Id of the uploaded solution file.") - * @Param(type="post", name="fileEntry", validation="string:0..255", required=false, - * description="Entry (relative path) within a ZIP package (if the uploaded file is a ZIP).") - * @Param(type="post", name="authorId", validation="string:36", - * description="Id of the author of the similar solutions/files.") - * @Param(type="post", name="similarity", validation="numeric", - * description="Relative similarity of the records associated with selected author [0-1].") - * @Param(type="post", name="files", validation="array", - * description="List of similar files and their records.") */ + #[Post("solutionFileId", new VUuid(), "Id of the uploaded solution file.")] + #[Post( + "fileEntry", + new VString(0, 255), + "Entry (relative path) within a ZIP package (if the uploaded file is a ZIP).", + required: false, + )] + #[Post("authorId", new VUuid(), "Id of the author of the similar solutions/files.")] + #[Post("similarity", new VDouble(), "Relative similarity of the records associated with selected author [0-1].")] + #[Post("files", new VArray(), "List of similar files and their records.")] + #[Path("id", new VString(), required: true)] + #[Path("solutionId", new VString(), required: true)] public function actionAddSimilarities(string $id, string $solutionId): void { $batch = $this->detectionBatches->findOrThrow($id); diff --git a/app/V1Module/presenters/ReferenceExerciseSolutionsPresenter.php b/app/V1Module/presenters/ReferenceExerciseSolutionsPresenter.php index 4fc1c94e9..bf31298b3 100644 --- a/app/V1Module/presenters/ReferenceExerciseSolutionsPresenter.php +++ b/app/V1Module/presenters/ReferenceExerciseSolutionsPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\BadRequestException; use App\Exceptions\ExerciseCompilationException; use App\Exceptions\ExerciseCompilationSoftException; @@ -170,8 +183,8 @@ public function checkSolutions(string $exerciseId) /** * Get reference solutions for an exercise * @GET - * @param string $exerciseId Identifier of the exercise */ + #[Path("exerciseId", new VString(), "Identifier of the exercise", required: true)] public function actionSolutions(string $exerciseId) { $exercise = $this->exercises->findOrThrow($exerciseId); @@ -200,9 +213,9 @@ public function checkDetail(string $solutionId) /** * Get details of a reference solution * @GET - * @param string $solutionId An identifier of the solution * @throws NotFoundException */ + #[Path("solutionId", new VString(), "An identifier of the solution", required: true)] public function actionDetail(string $solutionId) { $solution = $this->referenceSolutions->findOrThrow($solutionId); @@ -220,12 +233,11 @@ public function checkUpdate(string $solutionId) /** * Update details about the ref. solution (note, etc...) * @POST - * @Param(type="post", name="note", validation="string:0..65535", - * description="A description by the author of the solution") - * @param string $solutionId Identifier of the solution * @throws NotFoundException * @throws InternalServerException */ + #[Post("note", new VString(0, 65535), "A description by the author of the solution")] + #[Path("solutionId", new VString(), "Identifier of the solution", required: true)] public function actionUpdate(string $solutionId) { $req = $this->getRequest(); @@ -247,8 +259,8 @@ public function checkDeleteReferenceSolution(string $solutionId) /** * Delete reference solution with given identification. * @DELETE - * @param string $solutionId identifier of reference solution */ + #[Path("solutionId", new VString(), "identifier of reference solution", required: true)] public function actionDeleteReferenceSolution(string $solutionId) { $solution = $this->referenceSolutions->findOrThrow($solutionId); @@ -281,9 +293,9 @@ public function checkSubmissions(string $solutionId) /** * Get a list of submissions for given reference solution. * @GET - * @param string $solutionId identifier of the reference exercise solution * @throws InternalServerException */ + #[Path("solutionId", new VString(), "identifier of the reference exercise solution", required: true)] public function actionSubmissions(string $solutionId) { $solution = $this->referenceSolutions->findOrThrow($solutionId); @@ -308,10 +320,10 @@ public function checkSubmission(string $submissionId) /** * Get reference solution evaluation (i.e., submission) for an exercise solution. * @GET - * @param string $submissionId identifier of the reference exercise submission * @throws NotFoundException * @throws InternalServerException */ + #[Path("submissionId", new VString(), "identifier of the reference exercise submission", required: true)] public function actionSubmission(string $submissionId) { $submission = $this->referenceSubmissions->findOrThrow($submissionId); @@ -334,8 +346,8 @@ public function checkDeleteSubmission(string $submissionId) /** * Remove reference solution evaluation (submission) permanently. * @DELETE - * @param string $submissionId Identifier of the reference solution submission */ + #[Path("submissionId", new VString(), "Identifier of the reference solution submission", required: true)] public function actionDeleteSubmission(string $submissionId) { $submission = $this->referenceSubmissions->findOrThrow($submissionId); @@ -361,13 +373,13 @@ public function checkPreSubmit(string $exerciseId) * environments for the exercise. Also it can be further used for entry * points and other important things that should be provided by user during submit. * @POST - * @param string $exerciseId identifier of exercise - * @Param(type="post", name="files", validation="array", "Array of identifications of submitted files") * @throws NotFoundException * @throws InvalidArgumentException * @throws ExerciseConfigException * @throws BadRequestException */ + #[Post("files", new VArray())] + #[Path("exerciseId", new VString(), "identifier of exercise", required: true)] public function actionPreSubmit(string $exerciseId) { $exercise = $this->exercises->findOrThrow($exerciseId); @@ -413,18 +425,17 @@ public function checkSubmit(string $exerciseId) /** * Add new reference solution to an exercise * @POST - * @Param(type="post", name="note", validation="string", - * description="Description of this particular reference solution, for example used algorithm") - * @Param(type="post", name="files", description="Files of the reference solution") - * @Param(type="post", name="runtimeEnvironmentId", description="ID of runtime for this solution") - * @Param(type="post", name="solutionParams", required=false, description="Solution parameters") - * @param string $exerciseId Identifier of the exercise * @throws ForbiddenRequestException * @throws NotFoundException * @throws SubmissionEvaluationFailedException * @throws ParseException * @throws BadRequestException */ + #[Post("note", new VString(), "Description of this particular reference solution, for example used algorithm")] + #[Post("files", new VMixed(), "Files of the reference solution", nullable: true)] + #[Post("runtimeEnvironmentId", new VMixed(), "ID of runtime for this solution", nullable: true)] + #[Post("solutionParams", new VMixed(), "Solution parameters", required: false, nullable: true)] + #[Path("exerciseId", new VString(), "Identifier of the exercise", required: true)] public function actionSubmit(string $exerciseId) { $exercise = $this->exercises->findOrThrow($exerciseId); @@ -465,13 +476,12 @@ public function checkResubmit(string $id) /** * Evaluate a single reference exercise solution for all configured hardware groups * @POST - * @param string $id Identifier of the reference solution - * @Param(type="post", name="debug", validation="bool", required=false, - * description="Debugging evaluation with all logs and outputs") * @throws ForbiddenRequestException * @throws ParseException * @throws BadRequestException */ + #[Post("debug", new VBool(), "Debugging evaluation with all logs and outputs", required: false)] + #[Path("id", new VString(), "Identifier of the reference solution", required: true)] public function actionResubmit(string $id) { $req = $this->getRequest(); @@ -511,14 +521,13 @@ function ($solution) { /** * Evaluate all reference solutions for an exercise (and for all configured hardware groups). * @POST - * @param string $exerciseId Identifier of the exercise - * @Param(type="post", name="debug", validation="bool", required=false, - * description="Debugging evaluation with all logs and outputs") * @throws ForbiddenRequestException * @throws ParseException * @throws BadRequestException * @throws NotFoundException */ + #[Post("debug", new VBool(), "Debugging evaluation with all logs and outputs", required: false)] + #[Path("exerciseId", new VString(), "Identifier of the exercise", required: true)] public function actionResubmitAll($exerciseId) { $req = $this->getRequest(); @@ -610,11 +619,11 @@ public function checkDownloadSolutionArchive(string $solutionId) /** * Download archive containing all solution files for particular reference solution. * @GET - * @param string $solutionId of reference solution * @throws NotFoundException * @throws \Nette\Application\BadRequestException * @throws \Nette\Application\AbortException */ + #[Path("solutionId", new VString(), "of reference solution", required: true)] public function actionDownloadSolutionArchive(string $solutionId) { $solution = $this->referenceSolutions->findOrThrow($solutionId); @@ -636,10 +645,10 @@ public function checkFiles(string $id) /** * Get the list of submitted files of the solution. * @GET - * @param string $id of reference solution * @throws ForbiddenRequestException * @throws NotFoundException */ + #[Path("id", new VString(), "of reference solution", required: true)] public function actionFiles(string $id) { $solution = $this->referenceSolutions->findOrThrow($id)->getSolution(); @@ -660,13 +669,13 @@ public function checkDownloadResultArchive(string $submissionId) /** * Download result archive from backend for a reference solution evaluation * @GET - * @param string $submissionId * @throws ForbiddenRequestException * @throws NotFoundException * @throws NotReadyException * @throws InternalServerException * @throws \Nette\Application\AbortException */ + #[Path("submissionId", new VString(), required: true)] public function actionDownloadResultArchive(string $submissionId) { $submission = $this->referenceSubmissions->findOrThrow($submissionId); @@ -695,10 +704,10 @@ public function checkEvaluationScoreConfig(string $submissionId) /** * Get score configuration associated with given submission evaluation * @GET - * @param string $submissionId identifier of the reference exercise submission * @throws NotFoundException * @throws InternalServerException */ + #[Path("submissionId", new VString(), "identifier of the reference exercise submission", required: true)] public function actionEvaluationScoreConfig(string $submissionId) { $submission = $this->referenceSubmissions->findOrThrow($submissionId); @@ -720,13 +729,12 @@ public function checkSetVisibility(string $solutionId) /** * Set visibility of given reference solution. * @POST - * @param string $solutionId of reference solution - * @Param(type="post", name="visibility", required=true, validation="numericint", - * description="New visibility level.") * @throws NotFoundException * @throws ForbiddenRequestException * @throws BadRequestException */ + #[Post("visibility", new VInt(), "New visibility level.", required: true)] + #[Path("solutionId", new VString(), "of reference solution", required: true)] public function actionSetVisibility(string $solutionId) { $solution = $this->referenceSolutions->findOrThrow($solutionId); diff --git a/app/V1Module/presenters/RegistrationPresenter.php b/app/V1Module/presenters/RegistrationPresenter.php index a8753a3b3..ca281ef07 100644 --- a/app/V1Module/presenters/RegistrationPresenter.php +++ b/app/V1Module/presenters/RegistrationPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\FrontendErrorMappings; use App\Exceptions\InvalidArgumentException; use App\Exceptions\WrongCredentialsException; @@ -21,11 +34,15 @@ use App\Helpers\EmailVerificationHelper; use App\Helpers\RegistrationConfig; use App\Helpers\InvitationHelper; +use App\Helpers\MetaFormats\Attributes\Format; +use App\Helpers\MetaFormats\FormatDefinitions\UserFormat; +use App\Helpers\MetaFormats\Attributes\ParamAttribute; use App\Security\Roles; use App\Security\ACL\IUserPermissions; use App\Security\ACL\IGroupPermissions; use Nette\Http\IResponse; use Nette\Security\Passwords; +use Tracy\ILogger; use ZxcvbnPhp\Zxcvbn; /** @@ -144,23 +161,18 @@ public function checkCreateAccount() /** * Create a user account * @POST - * @Param(type="post", name="email", validation="email", description="An email that will serve as a login name") - * @Param(type="post", name="firstName", validation="string:2..", description="First name") - * @Param(type="post", name="lastName", validation="string:2..", description="Last name") - * @Param(type="post", name="password", validation="string:1..", msg="Password cannot be empty.", - * description="A password for authentication") - * @Param(type="post", name="passwordConfirm", validation="string:1..", msg="Confirm Password cannot be empty.", - * description="A password confirmation") - * @Param(type="post", name="instanceId", validation="string:1..", - * description="Identifier of the instance to register in") - * @Param(type="post", name="titlesBeforeName", required=false, validation="string:1..", - * description="Titles which is placed before user name") - * @Param(type="post", name="titlesAfterName", required=false, validation="string:1..", - * description="Titles which is placed after user name") * @throws BadRequestException * @throws WrongCredentialsException * @throws InvalidArgumentException */ + #[Post("email", new VEmail(), "An email that will serve as a login name")] + #[Post("firstName", new VString(2), "First name")] + #[Post("lastName", new VString(2), "Last name")] + #[Post("password", new VString(1), "A password for authentication")] + #[Post("passwordConfirm", new VString(1), "A password confirmation")] + #[Post("instanceId", new VString(1), "Identifier of the instance to register in")] + #[Post("titlesBeforeName", new VString(1), "Titles which is placed before user name", required: false)] + #[Post("titlesAfterName", new VString(1), "Titles which is placed after user name", required: false)] public function actionCreateAccount() { $req = $this->getRequest(); @@ -226,9 +238,9 @@ public function actionCreateAccount() /** * Check if the registered E-mail isn't already used and if the password is strong enough * @POST - * @Param(type="post", name="email", description="E-mail address (login name)") - * @Param(type="post", name="password", required=false, description="Authentication password") */ + #[Post("email", new VMixed(), "E-mail address (login name)", nullable: true)] + #[Post("password", new VMixed(), "Authentication password", required: false, nullable: true)] public function actionValidateRegistrationData() { $req = $this->getRequest(); @@ -259,34 +271,23 @@ public function checkCreateInvitation() /** * Create an invitation for a user and send it over via email * @POST - * @Param(type="post", name="email", validation="email", description="An email that will serve as a login name") - * @Param(type="post", name="firstName", required=true, validation="string:2..", description="First name") - * @Param(type="post", name="lastName", validation="string:2..", description="Last name") - * @Param(type="post", name="instanceId", validation="string:1..", - * description="Identifier of the instance to register in") - * @Param(type="post", name="titlesBeforeName", required=false, validation="string:1..", - * description="Titles which is placed before user name") - * @Param(type="post", name="titlesAfterName", required=false, validation="string:1..", - * description="Titles which is placed after user name") - * @Param(type="post", name="groups", required=false, validation="array", - * description="List of group IDs in which the user is added right after registration") - * @Param(type="post", name="locale", required=false, validation="string:2", - * description="Language used in the invitation email (en by default).") * @throws BadRequestException * @throws InvalidArgumentException */ + #[Format(UserFormat::class)] public function actionCreateInvitation() { - $req = $this->getRequest(); + /** @var UserFormat */ + $format = $this->getFormatInstance(); // check if the email is free - $email = trim($req->getPost("email")); + $email = trim($format->email); // username is name of column which holds login identifier represented by email if ($this->logins->getByUsername($email) !== null) { throw new BadRequestException("This email address is already taken."); } - $groupsIds = $req->getPost("groups") ?? []; + $groupsIds = $format->groups ?? []; foreach ($groupsIds as $id) { $group = $this->groups->get($id); if (!$group || $group->isOrganizational() || !$this->groupAcl->canInviteStudents($group)) { @@ -295,23 +296,23 @@ public function actionCreateInvitation() } // gather data - $instanceId = $req->getPost("instanceId"); + $instanceId = $format->instanceId; $instance = $this->getInstance($instanceId); - $titlesBeforeName = $req->getPost("titlesBeforeName") === null ? "" : $req->getPost("titlesBeforeName"); - $titlesAfterName = $req->getPost("titlesAfterName") === null ? "" : $req->getPost("titlesAfterName"); + $titlesBeforeName = $format->titlesBeforeName === null ? "" : $format->titlesBeforeName; + $titlesAfterName = $format->titlesAfterName === null ? "" : $format->titlesAfterName; // create the token and send it via email try { $this->invitationHelper->invite( $instanceId, $email, - $req->getPost("firstName"), - $req->getPost("lastName"), + $format->firstName, + $format->lastName, $titlesBeforeName, $titlesAfterName, $groupsIds, $this->getCurrentUser(), - $req->getPost("locale") ?? "en", + $format->locale ?? "en", ); } catch (InvalidAccessTokenException $e) { throw new BadRequestException( @@ -328,15 +329,12 @@ public function actionCreateInvitation() /** * Accept invitation and create corresponding user account. * @POST - * @Param(type="post", name="token", validation="string:1..", - * description="Token issued in create invitation process.") - * @Param(type="post", name="password", validation="string:1..", msg="Password cannot be empty.", - * description="A password for authentication") - * @Param(type="post", name="passwordConfirm", validation="string:1..", msg="Confirm Password cannot be empty.", - * description="A password confirmation") * @throws BadRequestException * @throws InvalidArgumentException */ + #[Post("token", new VString(1), "Token issued in create invitation process.")] + #[Post("password", new VString(1), "A password for authentication")] + #[Post("passwordConfirm", new VString(1), "A password confirmation")] public function actionAcceptInvitation() { $req = $this->getRequest(); diff --git a/app/V1Module/presenters/RuntimeEnvironmentsPresenter.php b/app/V1Module/presenters/RuntimeEnvironmentsPresenter.php index fbb1cd57b..5799d6761 100644 --- a/app/V1Module/presenters/RuntimeEnvironmentsPresenter.php +++ b/app/V1Module/presenters/RuntimeEnvironmentsPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\ForbiddenRequestException; use App\Model\Repository\RuntimeEnvironments; use App\Security\ACL\IRuntimeEnvironmentPermissions; diff --git a/app/V1Module/presenters/SecurityPresenter.php b/app/V1Module/presenters/SecurityPresenter.php index 476e41c33..7ff6a1aee 100644 --- a/app/V1Module/presenters/SecurityPresenter.php +++ b/app/V1Module/presenters/SecurityPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\InvalidArgumentException; use Exception; use Nette\Application\IPresenterFactory; @@ -25,9 +38,9 @@ class SecurityPresenter extends BasePresenter /** * @POST - * @Param(name="url", type="post", required=true, description="URL of the resource that we are checking") - * @Param(name="method", type="post", required=true, description="The HTTP method") */ + #[Post("url", new VMixed(), "URL of the resource that we are checking", required: true, nullable: true)] + #[Post("method", new VMixed(), "The HTTP method", required: true, nullable: true)] public function actionCheck() { $requestParams = $this->router->match( diff --git a/app/V1Module/presenters/ShadowAssignmentsPresenter.php b/app/V1Module/presenters/ShadowAssignmentsPresenter.php index 927bb41c7..51a356ac4 100644 --- a/app/V1Module/presenters/ShadowAssignmentsPresenter.php +++ b/app/V1Module/presenters/ShadowAssignmentsPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\BadRequestException; use App\Exceptions\ForbiddenRequestException; use App\Exceptions\InvalidArgumentException; @@ -90,9 +103,9 @@ public function checkDetail(string $id) /** * Get details of a shadow assignment * @GET - * @param string $id Identifier of the assignment * @throws NotFoundException */ + #[Path("id", new VString(), "Identifier of the assignment", required: true)] public function actionDetail(string $id) { $assignment = $this->shadowAssignments->findOrThrow($id); @@ -110,10 +123,10 @@ public function checkValidate(string $id) /** * Check if the version of the shadow assignment is up-to-date. * @POST - * @Param(type="post", name="version", validation="numericint", description="Version of the shadow assignment.") - * @param string $id Identifier of the shadow assignment * @throws ForbiddenRequestException */ + #[Post("version", new VInt(), "Version of the shadow assignment.")] + #[Path("id", new VString(), "Identifier of the shadow assignment", required: true)] public function actionValidate($id) { $assignment = $this->shadowAssignments->findOrThrow($id); @@ -136,25 +149,28 @@ public function checkUpdateDetail(string $id) /** * Update details of an shadow assignment * @POST - * @param string $id Identifier of the updated assignment - * @Param(type="post", name="version", validation="numericint", - * description="Version of the edited assignment") - * @Param(type="post", name="isPublic", validation="bool", - * description="Is the assignment ready to be displayed to students?") - * @Param(type="post", name="isBonus", validation="bool", - * description="If true, the points from this exercise will not be included in overall score of group") - * @Param(type="post", name="localizedTexts", validation="array", - * description="A description of the assignment") - * @Param(type="post", name="maxPoints", validation="numericint", - * description="A maximum of points that user can be awarded") - * @Param(type="post", name="sendNotification", required=false, validation="bool", - * description="If email notification should be sent") - * @Param(type="post", name="deadline", validation="timestamp|null", required=false, - * description="Deadline (only for visualization), missing value meas no deadline (same as null)") * @throws BadRequestException * @throws InvalidArgumentException * @throws NotFoundException */ + #[Post("version", new VInt(), "Version of the edited assignment")] + #[Post("isPublic", new VBool(), "Is the assignment ready to be displayed to students?")] + #[Post( + "isBonus", + new VBool(), + "If true, the points from this exercise will not be included in overall score of group", + )] + #[Post("localizedTexts", new VArray(), "A description of the assignment")] + #[Post("maxPoints", new VInt(), "A maximum of points that user can be awarded")] + #[Post("sendNotification", new VBool(), "If email notification should be sent", required: false)] + #[Post( + "deadline", + new VTimestamp(), + "Deadline (only for visualization), missing value meas no deadline (same as null)", + required: false, + nullable: true, + )] + #[Path("id", new VString(), "Identifier of the updated assignment", required: true)] public function actionUpdateDetail(string $id) { $assignment = $this->shadowAssignments->findOrThrow($id); @@ -242,11 +258,11 @@ public function actionUpdateDetail(string $id) /** * Create new shadow assignment in given group. * @POST - * @Param(type="post", name="groupId", description="Identifier of the group") * @throws ForbiddenRequestException * @throws BadRequestException * @throws NotFoundException */ + #[Post("groupId", new VMixed(), "Identifier of the group", nullable: true)] public function actionCreate() { $req = $this->getRequest(); @@ -277,9 +293,9 @@ public function checkRemove(string $id) /** * Delete shadow assignment * @DELETE - * @param string $id Identifier of the assignment to be removed * @throws NotFoundException */ + #[Path("id", new VString(), "Identifier of the assignment to be removed", required: true)] public function actionRemove(string $id) { $assignment = $this->shadowAssignments->findOrThrow($id); @@ -298,18 +314,21 @@ public function checkCreatePoints(string $id) /** * Create new points for shadow assignment and user. * @POST - * @param string $id Identifier of the shadow assignment - * @Param(type="post", name="userId", validation="string", - * description="Identifier of the user which is marked as awardee for points") - * @Param(type="post", name="points", validation="numericint", description="Number of points assigned to the user") - * @Param(type="post", name="note", validation="string", description="Note about newly created points") - * @Param(type="post", name="awardedAt", validation="timestamp", required=false, - * description="Datetime when the points were awarded, whatever that means") * @throws NotFoundException * @throws ForbiddenRequestException * @throws BadRequestException * @throws InvalidStateException */ + #[Post("userId", new VString(), "Identifier of the user which is marked as awardee for points")] + #[Post("points", new VInt(), "Number of points assigned to the user")] + #[Post("note", new VString(), "Note about newly created points")] + #[Post( + "awardedAt", + new VTimestamp(), + "Datetime when the points were awarded, whatever that means", + required: false, + )] + #[Path("id", new VString(), "Identifier of the shadow assignment", required: true)] public function actionCreatePoints(string $id) { $req = $this->getRequest(); @@ -362,14 +381,18 @@ public function checkUpdatePoints(string $pointsId) /** * Update detail of shadow assignment points. * @POST - * @param string $pointsId Identifier of the shadow assignment points - * @Param(type="post", name="points", validation="numericint", description="Number of points assigned to the user") - * @Param(type="post", name="note", validation="string:0..1024", description="Note about newly created points") - * @Param(type="post", name="awardedAt", validation="timestamp", required=false, - * description="Datetime when the points were awarded, whatever that means") * @throws NotFoundException * @throws InvalidStateException */ + #[Post("points", new VInt(), "Number of points assigned to the user")] + #[Post("note", new VString(0, 1024), "Note about newly created points")] + #[Post( + "awardedAt", + new VTimestamp(), + "Datetime when the points were awarded, whatever that means", + required: false, + )] + #[Path("pointsId", new VString(), "Identifier of the shadow assignment points", required: true)] public function actionUpdatePoints(string $pointsId) { $pointsEntity = $this->shadowAssignmentPointsRepository->findOrThrow($pointsId); @@ -408,9 +431,9 @@ public function checkRemovePoints(string $pointsId) /** * Remove points of shadow assignment. * @DELETE - * @param string $pointsId Identifier of the shadow assignment points * @throws NotFoundException */ + #[Path("pointsId", new VString(), "Identifier of the shadow assignment points", required: true)] public function actionRemovePoints(string $pointsId) { $points = $this->shadowAssignmentPointsRepository->findOrThrow($pointsId); diff --git a/app/V1Module/presenters/SisPresenter.php b/app/V1Module/presenters/SisPresenter.php index fc132cc57..7a0a019d2 100644 --- a/app/V1Module/presenters/SisPresenter.php +++ b/app/V1Module/presenters/SisPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\ApiException; use App\Exceptions\BadRequestException; use App\Exceptions\ForbiddenRequestException; @@ -134,12 +147,12 @@ public function checkRegisterTerm() /** * Register a new term * @POST - * @Param(name="year", type="post") - * @Param(name="term", type="post") * @throws InvalidArgumentException * @throws ForbiddenRequestException * @throws BadRequestException */ + #[Post("year", new VMixed(), nullable: true)] + #[Post("term", new VMixed(), nullable: true)] public function actionRegisterTerm() { $year = intval($this->getRequest()->getPost("year")); @@ -170,13 +183,13 @@ public function checkEditTerm(string $id) /** * Set details of a term * @POST - * @Param(name="beginning", type="post", validation="timestamp") - * @Param(name="end", type="post", validation="timestamp") - * @Param(name="advertiseUntil", type="post", validation="timestamp") - * @param string $id * @throws InvalidArgumentException * @throws NotFoundException */ + #[Post("beginning", new VTimestamp())] + #[Post("end", new VTimestamp())] + #[Post("advertiseUntil", new VTimestamp())] + #[Path("id", new VString(), required: true)] public function actionEditTerm(string $id) { $term = $this->sisValidTerms->findOrThrow($id); @@ -219,9 +232,9 @@ public function checkDeleteTerm(string $id) /** * Delete a term * @DELETE - * @param string $id * @throws NotFoundException */ + #[Path("id", new VString(), required: true)] public function actionDeleteTerm(string $id) { $term = $this->sisValidTerms->findOrThrow($id); @@ -246,12 +259,12 @@ public function checkSubscribedGroups($userId, $year, $term) * Each course holds bound group IDs and group objects are returned in a separate array. * Whole ancestral closure of groups is returned, so the webapp may properly assemble hiarichial group names. * @GET - * @param string $userId - * @param int $year - * @param int $term * @throws InvalidArgumentException * @throws BadRequestException */ + #[Path("userId", new VString(), required: true)] + #[Path("year", new VInt(), required: true)] + #[Path("term", new VInt(), required: true)] public function actionSubscribedCourses($userId, $year, $term) { $user = $this->users->findOrThrow($userId); @@ -313,13 +326,13 @@ public function checkSupervisedCourses($userId, $year, $term) * Each course holds bound group IDs and group objects are returned in a separate array. * Whole ancestral closure of groups is returned, so the webapp may properly assemble hiarichial group names. * @GET - * @param string $userId - * @param int $year - * @param int $term * @throws InvalidArgumentException * @throws NotFoundException * @throws BadRequestException */ + #[Path("userId", new VString(), required: true)] + #[Path("year", new VInt(), required: true)] + #[Path("term", new VInt(), required: true)] public function actionSupervisedCourses($userId, $year, $term) { $user = $this->users->findOrThrow($userId); @@ -405,13 +418,13 @@ private function makeCaptionsUnique(array &$captions, Group $parentGroup) /** * Create a new group based on a SIS group * @POST - * @param string $courseId * @throws BadRequestException - * @Param(name="parentGroupId", type="post") * @throws ForbiddenRequestException * @throws InvalidArgumentException * @throws Exception */ + #[Post("parentGroupId", new VMixed(), nullable: true)] + #[Path("courseId", new VString(), required: true)] public function actionCreateGroup($courseId) { $user = $this->getCurrentUser(); @@ -479,12 +492,12 @@ public function actionCreateGroup($courseId) /** * Bind an existing local group to a SIS group * @POST - * @param string $courseId * @throws ApiException * @throws ForbiddenRequestException * @throws BadRequestException - * @Param(name="groupId", type="post") */ + #[Post("groupId", new VMixed(), nullable: true)] + #[Path("courseId", new VString(), required: true)] public function actionBindGroup($courseId) { $user = $this->getCurrentUser(); @@ -512,13 +525,13 @@ public function actionBindGroup($courseId) /** * Delete a binding between a local group and a SIS group * @DELETE - * @param string $courseId an identifier of a SIS course - * @param string $groupId an identifier of a local group * @throws BadRequestException * @throws ForbiddenRequestException * @throws InvalidArgumentException * @throws NotFoundException */ + #[Path("courseId", new VString(), "an identifier of a SIS course", required: true)] + #[Path("groupId", new VString(), "an identifier of a local group", required: true)] public function actionUnbindGroup($courseId, $groupId) { $user = $this->getCurrentUser(); @@ -542,11 +555,11 @@ public function actionUnbindGroup($courseId, $groupId) /** * Find groups that can be chosen as parents of a group created from given SIS group by current user * @GET - * @param string $courseId * @throws ApiException * @throws ForbiddenRequestException * @throws BadRequestException */ + #[Path("courseId", new VString(), required: true)] public function actionPossibleParents($courseId) { $sisUserId = $this->getSisUserIdOrThrow($this->getCurrentUser()); diff --git a/app/V1Module/presenters/SubmissionFailuresPresenter.php b/app/V1Module/presenters/SubmissionFailuresPresenter.php index f74aa8e65..96b33e2e5 100644 --- a/app/V1Module/presenters/SubmissionFailuresPresenter.php +++ b/app/V1Module/presenters/SubmissionFailuresPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\BadRequestException; use App\Exceptions\ForbiddenRequestException; use App\Helpers\Notifications\FailureResolutionEmailsSender; @@ -84,8 +97,8 @@ public function checkDetail(string $id) /** * Get details of a failure * @GET - * @param string $id An identifier of the failure */ + #[Path("id", new VString(), "An identifier of the failure", required: true)] public function actionDetail(string $id) { $failure = $this->submissionFailures->findOrThrow($id); @@ -103,12 +116,10 @@ public function checkResolve(string $id) /** * Mark a submission failure as resolved * @POST - * @param string $id An identifier of the failure - * @Param(name="note", type="post", validation="string:0..255", required=false, - * description="Brief description of how the failure was resolved") - * @Param(name="sendEmail", type="post", validation="bool", - * description="True if email should be sent to the author of submission") */ + #[Post("note", new VString(0, 255), "Brief description of how the failure was resolved", required: false)] + #[Post("sendEmail", new VBool(), "True if email should be sent to the author of submission")] + #[Path("id", new VString(), "An identifier of the failure", required: true)] public function actionResolve(string $id) { $failure = $this->submissionFailures->findOrThrow($id); diff --git a/app/V1Module/presenters/SubmitPresenter.php b/app/V1Module/presenters/SubmitPresenter.php index 7b14cfb16..9e27b82ce 100644 --- a/app/V1Module/presenters/SubmitPresenter.php +++ b/app/V1Module/presenters/SubmitPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\ExerciseCompilationException; use App\Exceptions\ExerciseCompilationSoftException; use App\Exceptions\ExerciseConfigException; @@ -207,11 +220,11 @@ public function checkCanSubmit(string $id, string $userId = null) /** * Check if the given user can submit solutions to the assignment * @GET - * @param string $id Identifier of the assignment - * @param string|null $userId Identification of the user * @throws ForbiddenRequestException * @throws NotFoundException */ + #[Path("id", new VString(), "Identifier of the assignment", required: true)] + #[Query("userId", new VString(), "Identification of the user", required: false, nullable: true)] public function actionCanSubmit(string $id, string $userId = null) { $assignment = $this->assignments->findOrThrow($id); @@ -231,19 +244,22 @@ public function actionCanSubmit(string $id, string $userId = null) /** * Submit a solution of an assignment * @POST - * @Param(type="post", name="note", validation="string:0..1024", - * description="A note by the author of the solution") - * @Param(type="post", name="userId", required=false, description="Author of the submission") - * @Param(type="post", name="files", description="Submitted files") - * @Param(type="post", name="runtimeEnvironmentId", - * description="Identifier of the runtime environment used for evaluation") - * @Param(type="post", name="solutionParams", required=false, description="Solution parameters") - * @param string $id Identifier of the assignment * @throws ForbiddenRequestException * @throws InvalidArgumentException * @throws NotFoundException * @throws ParseException */ + #[Post("note", new VString(0, 1024), "A note by the author of the solution")] + #[Post("userId", new VMixed(), "Author of the submission", required: false, nullable: true)] + #[Post("files", new VMixed(), "Submitted files", nullable: true)] + #[Post( + "runtimeEnvironmentId", + new VMixed(), + "Identifier of the runtime environment used for evaluation", + nullable: true, + )] + #[Post("solutionParams", new VMixed(), "Solution parameters", required: false, nullable: true)] + #[Path("id", new VString(), "Identifier of the assignment", required: true)] public function actionSubmit(string $id) { $this->assignments->beginTransaction(); @@ -340,14 +356,13 @@ public function checkResubmit(string $id) /** * Resubmit a solution (i.e., create a new submission) * @POST - * @param string $id Identifier of the solution - * @Param(type="post", name="debug", validation="bool", required=false, - * "Debugging resubmit with all logs and outputs") * @throws ForbiddenRequestException * @throws InvalidArgumentException * @throws NotFoundException * @throws ParseException */ + #[Post("debug", new VBool(), "Debugging resubmit with all logs and outputs", required: false)] + #[Path("id", new VString(), "Identifier of the solution", required: true)] public function actionResubmit(string $id) { $req = $this->getRequest(); @@ -369,10 +384,10 @@ public function checkResubmitAllAsyncJobStatus(string $id) * Return a list of all pending resubmit async jobs associated with given assignment. * Under normal circumstances, the list shoul be either empty, or contian only one job. * @GET - * @param string $id Identifier of the assignment * @throws ForbiddenRequestException * @throws NotFoundException */ + #[Path("id", new VString(), "Identifier of the assignment", required: true)] public function actionResubmitAllAsyncJobStatus(string $id) { $assignment = $this->assignments->findOrThrow($id); @@ -394,10 +409,10 @@ public function checkResubmitAll(string $id) * No job is started when there are pending resubmit jobs for the selected assignment. * Returns list of pending async jobs (same as GET call) * @POST - * @param string $id Identifier of the assignment * @throws ForbiddenRequestException * @throws NotFoundException */ + #[Path("id", new VString(), "Identifier of the assignment", required: true)] public function actionResubmitAll(string $id) { $assignment = $this->assignments->findOrThrow($id); @@ -431,13 +446,13 @@ public function checkPreSubmit(string $id, string $userId = null) * points and other important things that should be provided by user during * submit. * @POST - * @param string $id identifier of assignment - * @param string|null $userId Identifier of the submission author * @throws ExerciseConfigException * @throws InvalidArgumentException * @throws NotFoundException - * @Param(type="post", name="files", validation="array", "Array of identifications of submitted files") */ + #[Post("files", new VArray())] + #[Path("id", new VString(), "identifier of assignment", required: true)] + #[Query("userId", new VString(), "Identifier of the submission author", required: false, nullable: true)] public function actionPreSubmit(string $id, string $userId = null) { $assignment = $this->assignments->findOrThrow($id); diff --git a/app/V1Module/presenters/UploadedFilesPresenter.php b/app/V1Module/presenters/UploadedFilesPresenter.php index 65a99dbda..77a986d00 100644 --- a/app/V1Module/presenters/UploadedFilesPresenter.php +++ b/app/V1Module/presenters/UploadedFilesPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\CannotReceiveUploadedFileException; use App\Exceptions\BadRequestException; use App\Exceptions\ForbiddenRequestException; @@ -122,8 +135,8 @@ public function checkDetail(string $id) * Get details of a file * @GET * @LoggedIn - * @param string $id Identifier of the uploaded file */ + #[Path("id", new VString(), "Identifier of the uploaded file", required: true)] public function actionDetail(string $id) { $file = $this->uploadedFiles->findOrThrow($id); @@ -164,15 +177,22 @@ public function checkDownload(string $id, ?string $entry = null, ?string $simila /** * Download a file * @GET - * @param string $id Identifier of the file - * @Param(type="query", name="entry", required=false, validation="string:1..", - * description="Name of the entry in the ZIP archive (if the target file is ZIP)") - * @Param(type="query", name="similarSolutionId", required=false, validation="string:36", - * description="Id of an assignment solution which has detected possible plagiarism in this file. - * This is basically a shortcut (hint) for ACLs.") * @throws \Nette\Application\AbortException * @throws \Nette\Application\BadRequestException */ + #[Query( + "entry", + new VString(1), + "Name of the entry in the ZIP archive (if the target file is ZIP)", + required: false, + )] + #[Query( + "similarSolutionId", + new VUuid(), + "Id of an assignment solution which has detected possible plagiarism in this file. This is basically a shortcut (hint) for ACLs.", + required: false, + )] + #[Path("id", new VString(), "Identifier of the file", required: true)] public function actionDownload(string $id, ?string $entry = null) { $fileEntity = $this->uploadedFiles->findOrThrow($id); @@ -206,13 +226,20 @@ public function checkContent(string $id, ?string $entry = null, ?string $similar /** * Get the contents of a file * @GET - * @param string $id Identifier of the file - * @Param(type="query", name="entry", required=false, validation="string:1..", - * description="Name of the entry in the ZIP archive (if the target file is ZIP)") - * @Param(type="query", name="similarSolutionId", required=false, validation="string:36", - * description="Id of an assignment solution which has detected possible plagiarism in this file. - * This is basically a shortcut (hint) for ACLs.") */ + #[Query( + "entry", + new VString(1), + "Name of the entry in the ZIP archive (if the target file is ZIP)", + required: false, + )] + #[Query( + "similarSolutionId", + new VUuid(), + "Id of an assignment solution which has detected possible plagiarism in this file. This is basically a shortcut (hint) for ACLs.", + required: false, + )] + #[Path("id", new VString(), "Identifier of the file", required: true)] public function actionContent(string $id, ?string $entry = null) { $fileEntity = $this->uploadedFiles->findOrThrow($id); @@ -266,8 +293,8 @@ public function checkDigest(string $id) * Compute a digest using a hashing algorithm. This feature is intended for upload checksums only. * In the future, we might want to add algorithm selection via query parameter (default is SHA1). * @GET - * @param string $id Identifier of the file */ + #[Path("id", new VString(), "Identifier of the file", required: true)] public function actionDigest(string $id) { $fileEntity = $this->uploadedFiles->findOrThrow($id); @@ -362,10 +389,9 @@ public function checkStartPartial() * each one carrying a chunk of data. Once all the chunks are in place, the complete request assembles * them together in one file and transforms UploadPartialFile into UploadFile entity. * @POST - * @Param(type="post", name="name", required=true, validation="string:1..255", - * description="Name of the uploaded file.") - * @Param(type="post", name="size", required=true, validation="numericint", description="Total size in bytes.") */ + #[Post("name", new VString(1, 255), "Name of the uploaded file.", required: true)] + #[Post("size", new VInt(), "Total size in bytes.", required: true)] public function actionStartPartial() { $user = $this->getCurrentUser(); @@ -410,15 +436,14 @@ public function checkAppendPartial(string $id) /** * Add another chunk to partial upload. * @PUT - * @param string $id Identifier of the file - * @Param(type="query", name="offset", required="true", validation="numericint", - * description="Offset of the chunk for verification") * @throws InvalidArgumentException * @throws ForbiddenRequestException * @throws BadRequestException * @throws CannotReceiveUploadedFileException * @throws InternalServerException */ + #[Query("offset", new VInt(), "Offset of the chunk for verification", required: true)] + #[Path("id", new VString(), "Identifier of the file", required: true)] public function actionAppendPartial(string $id, int $offset) { $partialFile = $this->uploadedPartialFiles->findOrThrow($id); @@ -463,6 +488,7 @@ public function checkCancelPartial(string $id) * Cancel partial upload and remove all uploaded chunks. * @DELETE */ + #[Path("id", new VString(), required: true)] public function actionCancelPartial(string $id) { $partialFile = $this->uploadedPartialFiles->findOrThrow($id); @@ -506,6 +532,7 @@ public function checkCompletePartial(string $id) * All data chunks are extracted from the store, assembled into one file, and is moved back into the store. * @POST */ + #[Path("id", new VString(), required: true)] public function actionCompletePartial(string $id) { $partialFile = $this->uploadedPartialFiles->findOrThrow($id); @@ -567,11 +594,11 @@ public function checkDownloadSupplementaryFile(string $id) /** * Download supplementary file * @GET - * @param string $id Identifier of the file * @throws ForbiddenRequestException * @throws NotFoundException * @throws \Nette\Application\AbortException */ + #[Path("id", new VString(), "Identifier of the file", required: true)] public function actionDownloadSupplementaryFile(string $id) { $fileEntity = $this->supplementaryFiles->findOrThrow($id); diff --git a/app/V1Module/presenters/UserCalendarsPresenter.php b/app/V1Module/presenters/UserCalendarsPresenter.php index e8373a526..7f6090115 100644 --- a/app/V1Module/presenters/UserCalendarsPresenter.php +++ b/app/V1Module/presenters/UserCalendarsPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\BadRequestException; use App\Exceptions\ForbiddenRequestException; use App\Model\Entity\User; @@ -116,8 +129,8 @@ private function createDeadlineEvent(User $user, Assignment $assignment, string /** * Get calendar values in iCal format that correspond to given token. * @GET - * @param string $id the iCal token */ + #[Path("id", new VString(), "the iCal token", required: true)] public function actionDefault(string $id) { $calendar = $this->userCalendars->findOrThrow($id); @@ -179,8 +192,8 @@ public function checkUserCalendars(string $id) /** * Get all iCal tokens of one user (including expired ones). * @GET - * @param string $id of the user */ + #[Path("id", new VString(), "of the user", required: true)] public function actionUserCalendars(string $id) { $user = $this->users->findOrThrow($id); @@ -199,8 +212,8 @@ public function checkCreateCalendar(string $id) /** * Create new iCal token for a particular user. * @POST - * @param string $id of the user */ + #[Path("id", new VString(), "of the user", required: true)] public function actionCreateCalendar(string $id) { $user = $this->users->findOrThrow($id); @@ -221,8 +234,8 @@ public function checkExpireCalendar(string $id) /** * Set given iCal token to expired state. Expired tokens cannot be used to retrieve calendars. * @DELETE - * @param string $id the iCal token */ + #[Path("id", new VString(), "the iCal token", required: true)] public function actionExpireCalendar(string $id) { $calendar = $this->userCalendars->findOrThrow($id); diff --git a/app/V1Module/presenters/UsersPresenter.php b/app/V1Module/presenters/UsersPresenter.php index 5fa7780f1..7ace3f00f 100644 --- a/app/V1Module/presenters/UsersPresenter.php +++ b/app/V1Module/presenters/UsersPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Exceptions\ForbiddenRequestException; use App\Exceptions\FrontendErrorMappings; use App\Exceptions\InvalidArgumentException; @@ -110,12 +123,24 @@ public function checkDefault() * Get a list of all users matching given filters in given pagination rage. * The result conforms to pagination protocol. * @GET - * @param int $offset Index of the first result. - * @param int|null $limit Maximal number of results returned. - * @param string|null $orderBy Name of the column (column concept). The '!' prefix indicate descending order. - * @param array|null $filters Named filters that prune the result. - * @param string|null $locale Currently set locale (used to augment order by clause if necessary), */ + #[Query("offset", new VInt(), "Index of the first result.", required: false)] + #[Query("limit", new VInt(), "Maximal number of results returned.", required: false, nullable: true)] + #[Query( + "orderBy", + new VString(), + "Name of the column (column concept). The '!' prefix indicate descending order.", + required: false, + nullable: true, + )] + #[Query("filters", new VArray(), "Named filters that prune the result.", required: false, nullable: true)] + #[Query( + "locale", + new VString(), + "Currently set locale (used to augment order by clause if necessary),", + required: false, + nullable: true, + )] public function actionDefault( int $offset = 0, int $limit = null, @@ -151,8 +176,8 @@ public function checkListByIds() /** * Get a list of users based on given ids. * @POST - * @Param(type="post", name="ids", validation="array", description="Identifications of users") */ + #[Post("ids", new VArray(), "Identifications of users")] public function actionListByIds() { $users = $this->users->findByIds($this->getRequest()->getPost("ids")); @@ -176,8 +201,8 @@ public function checkDetail(string $id) /** * Get details of a user account * @GET - * @param string $id Identifier of the user */ + #[Path("id", new VString(), "Identifier of the user", required: true)] public function actionDetail(string $id) { $user = $this->users->findOrThrow($id); @@ -195,9 +220,9 @@ public function checkDelete(string $id) /** * Delete a user account * @DELETE - * @param string $id Identifier of the user * @throws ForbiddenRequestException */ + #[Path("id", new VString(), "Identifier of the user", required: true)] public function actionDelete(string $id) { $user = $this->users->findOrThrow($id); @@ -218,26 +243,22 @@ public function checkUpdateProfile(string $id) /** * Update the profile associated with a user account * @POST - * @param string $id Identifier of the user * @throws BadRequestException * @throws ForbiddenRequestException * @throws InvalidArgumentException - * @Param(type="post", name="firstName", required=false, validation="string:2..", description="First name") - * @Param(type="post", name="lastName", required=false, validation="string:2..", description="Last name") - * @Param(type="post", name="titlesBeforeName", required=false, description="Titles before name") - * @Param(type="post", name="titlesAfterName", required=false, description="Titles after name") - * @Param(type="post", name="email", validation="email", description="New email address", required=false) - * @Param(type="post", name="oldPassword", required=false, validation="string:1..", - * description="Old password of current user") - * @Param(type="post", name="password", required=false, validation="string:1..", - * description="New password of current user") - * @Param(type="post", name="passwordConfirm", required=false, validation="string:1..", - * description="Confirmation of new password of current user") - * @Param(type="post", name="gravatarUrlEnabled", validation="bool", required=false, - * description="Enable or disable gravatar profile image") * @throws WrongCredentialsException * @throws NotFoundException */ + #[Post("firstName", new VString(2), "First name", required: false)] + #[Post("lastName", new VString(2), "Last name", required: false)] + #[Post("titlesBeforeName", new VMixed(), "Titles before name", required: false, nullable: true)] + #[Post("titlesAfterName", new VMixed(), "Titles after name", required: false, nullable: true)] + #[Post("email", new VEmail(), "New email address", required: false)] + #[Post("oldPassword", new VString(1), "Old password of current user", required: false)] + #[Post("password", new VString(1), "New password of current user", required: false)] + #[Post("passwordConfirm", new VString(1), "Confirmation of new password of current user", required: false)] + #[Post("gravatarUrlEnabled", new VBool(), "Enable or disable gravatar profile image", required: false)] + #[Path("id", new VString(), "Identifier of the user", required: true)] public function actionUpdateProfile(string $id) { $req = $this->getRequest(); @@ -429,36 +450,76 @@ public function checkUpdateSettings(string $id) /** * Update the profile settings * @POST - * @param string $id Identifier of the user - * @Param(type="post", name="defaultLanguage", validation="string", required=false, - * description="Default language of UI") - * @Param(type="post", name="newAssignmentEmails", validation="bool", required=false, - * description="Flag if email should be sent to user when new assignment was created") - * @Param(type="post", name="assignmentDeadlineEmails", validation="bool", required=false, - * description="Flag if email should be sent to user if assignment deadline is nearby") - * @Param(type="post", name="submissionEvaluatedEmails", validation="bool", required=false, - * description="Flag if email should be sent to user when resubmission was evaluated") - * @Param(type="post", name="solutionCommentsEmails", validation="bool", required=false, - * description="Flag if email should be sent to user when new submission comment is added") - * @Param(type="post", name="solutionReviewsEmails", validation="bool", required=false, - * description="Flag enabling review-related email notifications sent to the author of the solution") - * @Param(type="post", name="pointsChangedEmails", validation="bool", required=false, - * description="Flag if email should be sent to user when the points were awarded for assignment") - * @Param(type="post", name="assignmentSubmitAfterAcceptedEmails", validation="bool", required=false, - * description="Flag if email should be sent to group supervisor if a student submits new solution - * for already accepted assignment") - * @Param(type="post", name="assignmentSubmitAfterReviewedEmails", validation="bool", required=false, - * description="Flag if email should be sent to group supervisor if a student submits new solution - * for already reviewed and not accepted assignment") - * @Param(type="post", name="exerciseNotificationEmails", validation="bool", required=false, - * description="Flag if notifications sent by authors of exercises should be sent via email.") - * @Param(type="post", name="solutionAcceptedEmails", validation="bool", required=false, - * description="Flag if notification should be sent to a student when solution accepted flag is changed.") - * @Param(type="post", name="solutionReviewRequestedEmails", validation="bool", required=false, - * description="Flag if notification should be send to a teacher when a solution reviewRequested flag - * is chagned in a supervised/admined group.") * @throws NotFoundException */ + #[Post("defaultLanguage", new VString(), "Default language of UI", required: false)] + #[Post( + "newAssignmentEmails", + new VBool(), + "Flag if email should be sent to user when new assignment was created", + required: false, + )] + #[Post( + "assignmentDeadlineEmails", + new VBool(), + "Flag if email should be sent to user if assignment deadline is nearby", + required: false, + )] + #[Post( + "submissionEvaluatedEmails", + new VBool(), + "Flag if email should be sent to user when resubmission was evaluated", + required: false, + )] + #[Post( + "solutionCommentsEmails", + new VBool(), + "Flag if email should be sent to user when new submission comment is added", + required: false, + )] + #[Post( + "solutionReviewsEmails", + new VBool(), + "Flag enabling review-related email notifications sent to the author of the solution", + required: false, + )] + #[Post( + "pointsChangedEmails", + new VBool(), + "Flag if email should be sent to user when the points were awarded for assignment", + required: false, + )] + #[Post( + "assignmentSubmitAfterAcceptedEmails", + new VBool(), + "Flag if email should be sent to group supervisor if a student submits new solution for already accepted assignment", + required: false, + )] + #[Post( + "assignmentSubmitAfterReviewedEmails", + new VBool(), + "Flag if email should be sent to group supervisor if a student submits new solution for already reviewed and not accepted assignment", + required: false, + )] + #[Post( + "exerciseNotificationEmails", + new VBool(), + "Flag if notifications sent by authors of exercises should be sent via email.", + required: false, + )] + #[Post( + "solutionAcceptedEmails", + new VBool(), + "Flag if notification should be sent to a student when solution accepted flag is changed.", + required: false, + )] + #[Post( + "solutionReviewRequestedEmails", + new VBool(), + "Flag if notification should be send to a teacher when a solution reviewRequested flag is chagned in a supervised/admined group.", + required: false, + )] + #[Path("id", new VString(), "Identifier of the user", required: true)] public function actionUpdateSettings(string $id) { $req = $this->getRequest(); @@ -508,12 +569,16 @@ public function checkUpdateUiData(string $id) /** * Update the user-specific structured UI data * @POST - * @param string $id Identifier of the user - * @Param(type="post", name="uiData", validation="array|null", description="Structured user-specific UI data") - * @Param(type="post", name="overwrite", validation="bool", required=false, - * description="Flag indicating that uiData should be overwritten completelly (instead of regular merge)") * @throws NotFoundException */ + #[Post("uiData", new VArray(), "Structured user-specific UI data", nullable: true)] + #[Post( + "overwrite", + new VBool(), + "Flag indicating that uiData should be overwritten completelly (instead of regular merge)", + required: false, + )] + #[Path("id", new VString(), "Identifier of the user", required: true)] public function actionUpdateUiData(string $id) { $req = $this->getRequest(); @@ -567,9 +632,9 @@ public function checkCreateLocalAccount(string $id) * If user is registered externally, add local account as another login method. * Created password is empty and has to be changed in order to use it. * @POST - * @param string $id * @throws InvalidArgumentException */ + #[Path("id", new VString(), required: true)] public function actionCreateLocalAccount(string $id) { $user = $this->users->findOrThrow($id); @@ -591,8 +656,8 @@ public function checkGroups(string $id) /** * Get a list of non-archived groups for a user * @GET - * @param string $id Identifier of the user */ + #[Path("id", new VString(), "Identifier of the user", required: true)] public function actionGroups(string $id) { $user = $this->users->findOrThrow($id); @@ -634,8 +699,8 @@ public function checkAllGroups(string $id) /** * Get a list of all groups for a user * @GET - * @param string $id Identifier of the user */ + #[Path("id", new VString(), "Identifier of the user", required: true)] public function actionAllGroups(string $id) { $user = $this->users->findOrThrow($id); @@ -659,9 +724,9 @@ public function checkInstances(string $id) /** * Get a list of instances where a user is registered * @GET - * @param string $id Identifier of the user * @throws NotFoundException */ + #[Path("id", new VString(), "Identifier of the user", required: true)] public function actionInstances(string $id) { $user = $this->users->findOrThrow($id); @@ -687,12 +752,11 @@ public function checkSetRole(string $id) /** * Set a given role to the given user. * @POST - * @param string $id Identifier of the user - * @Param(type="post", name="role", validation="string:1..", - * description="Role which should be assigned to the user") * @throws InvalidArgumentException * @throws NotFoundException */ + #[Post("role", new VString(1), "Role which should be assigned to the user")] + #[Path("id", new VString(), "Identifier of the user", required: true)] public function actionSetRole(string $id) { $user = $this->users->findOrThrow($id); @@ -719,10 +783,10 @@ public function checkInvalidateTokens(string $id) /** * Invalidate all existing tokens issued for given user * @POST - * @param string $id Identifier of the user * @throws ForbiddenRequestException * @throws NotFoundException */ + #[Path("id", new VString(), "Identifier of the user", required: true)] public function actionInvalidateTokens(string $id) { $user = $this->users->findOrThrow($id); @@ -758,12 +822,11 @@ public function checkSetAllowed(string $id) /** * Set "isAllowed" flag of the given user. The flag determines whether a user may perform any operation of the API. * @POST - * @param string $id Identifier of the user - * @Param(type="post", name="isAllowed", validation="bool", - * description="Whether the user is allowed (active) or not.") * @throws InvalidArgumentException * @throws NotFoundException */ + #[Post("isAllowed", new VBool(), "Whether the user is allowed (active) or not.")] + #[Path("id", new VString(), "Identifier of the user", required: true)] public function actionSetAllowed(string $id) { $user = $this->users->findOrThrow($id); @@ -786,11 +849,11 @@ public function checkUpdateExternalLogin(string $id, string $service) /** * Add or update existing external ID of given authentication service. * @POST - * @param string $id identifier of the user - * @param string $service identifier of the authentication service (login type) - * @Param(type="post", name="externalId", validation="string:1..128") * @throws InvalidArgumentException */ + #[Post("externalId", new VString(1, 128))] + #[Path("id", new VString(), "identifier of the user", required: true)] + #[Path("service", new VString(), "identifier of the authentication service (login type)", required: true)] public function actionUpdateExternalLogin(string $id, string $service) { $user = $this->users->findOrThrow($id); @@ -833,9 +896,9 @@ public function checkRemoveExternalLogin(string $id, string $service) /** * Remove external ID of given authentication service. * @DELETE - * @param string $id identifier of the user - * @param string $service identifier of the authentication service (login type) */ + #[Path("id", new VString(), "identifier of the user", required: true)] + #[Path("service", new VString(), "identifier of the authentication service (login type)", required: true)] public function actionRemoveExternalLogin(string $id, string $service) { $user = $this->users->findOrThrow($id); diff --git a/app/V1Module/presenters/WorkerFilesPresenter.php b/app/V1Module/presenters/WorkerFilesPresenter.php index 471073fe9..c9a7b5b6e 100644 --- a/app/V1Module/presenters/WorkerFilesPresenter.php +++ b/app/V1Module/presenters/WorkerFilesPresenter.php @@ -2,6 +2,19 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\Post; +use App\Helpers\MetaFormats\Attributes\Query; +use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\Type; +use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VBool; +use App\Helpers\MetaFormats\Validators\VDouble; +use App\Helpers\MetaFormats\Validators\VEmail; +use App\Helpers\MetaFormats\Validators\VInt; +use App\Helpers\MetaFormats\Validators\VMixed; +use App\Helpers\MetaFormats\Validators\VString; +use App\Helpers\MetaFormats\Validators\VTimestamp; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Helpers\BasicAuthHelper; use App\Helpers\WorkerFilesConfig; use App\Helpers\FileStorageManager; @@ -91,9 +104,9 @@ public function startup() * Sends over a ZIP file containing submitted files and YAML job config. * The ZIP is created if necessary. * @GET - * @param string $type of the submission job ("reference" or "student") - * @param string $id of the submission whose ZIP archive is to be served */ + #[Path("type", new VString(), "of the submission job (\"reference\" or \"student\")", required: true)] + #[Path("id", new VString(), "of the submission whose ZIP archive is to be served", required: true)] public function actionDownloadSubmissionArchive(string $type, string $id) { $file = $this->fileStorage->getWorkerSubmissionArchive($type, $id); @@ -112,8 +125,8 @@ public function actionDownloadSubmissionArchive(string $type, string $id) /** * Sends over an exercise supplementary file (a data file required by the tests). * @GET - * @param string $hash identification of the supplementary file */ + #[Path("hash", new VString(), "identification of the supplementary file", required: true)] public function actionDownloadSupplementaryFile(string $hash) { $file = $this->fileStorage->getSupplementaryFileByHash($hash); @@ -126,10 +139,10 @@ public function actionDownloadSupplementaryFile(string $hash) /** * Uploads a ZIP archive with results and logs (or everything in case of debug evaluations). * @PUT - * @param string $type of the submission job ("reference" or "student") - * @param string $id of the submission whose results archive is being uploaded * @throws UploadedFileException */ + #[Path("type", new VString(), "of the submission job (\"reference\" or \"student\")", required: true)] + #[Path("id", new VString(), "of the submission whose results archive is being uploaded", required: true)] public function actionUploadResultsFile(string $type, string $id) { try { diff --git a/app/V1Module/presenters/base/BasePresenter.php b/app/V1Module/presenters/base/BasePresenter.php index 893c20c19..b50d3c1ec 100644 --- a/app/V1Module/presenters/base/BasePresenter.php +++ b/app/V1Module/presenters/base/BasePresenter.php @@ -2,6 +2,7 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\MetaFormatHelper; use App\Helpers\Pagination; use App\Model\Entity\User; use App\Security\AccessToken; @@ -20,6 +21,11 @@ use App\Helpers\Validators; use App\Helpers\FileStorage\IImmutableFile; use App\Helpers\AnnotationsParser; +use App\Helpers\MetaFormats\FormatCache; +use App\Helpers\MetaFormats\MetaFormat; +use App\Helpers\MetaFormats\MetaRequest; +use App\Helpers\MetaFormats\RequestParamData; +use App\Helpers\MetaFormats\Type; use App\Responses\StorageFileResponse; use App\Responses\ZipFilesResponse; use Nette\Application\Application; @@ -69,8 +75,8 @@ class BasePresenter extends \App\Presenters\BasePresenter */ public $logger; - /** @var object Processed parameters from annotations */ - protected $parameters; + /** @var MetaFormat Instance of the meta format used by the endpoint (null if no format used) */ + private MetaFormat $requestFormatInstance; protected function formatPermissionCheckMethod($action) { @@ -105,7 +111,6 @@ public function startup() { parent::startup(); $this->application->errorPresenter = "V1:ApiError"; - $this->parameters = new \stdClass(); try { $presenterReflection = new ReflectionClass($this); @@ -199,35 +204,89 @@ protected function isInScope(string $scope): bool return $identity->isInScope($scope); } + public function getFormatInstance(): MetaFormat + { + return $this->requestFormatInstance; + } + private function processParams(ReflectionMethod $reflection) { - $annotations = AnnotationsParser::getAll($reflection); - $requiredFields = Arrays::get($annotations, "Param", []); - - foreach ($requiredFields as $field) { - $type = strtolower($field->type); - $name = $field->name; - $validationRule = isset($field->validation) ? $field->validation : null; - $msg = isset($field->msg) ? $field->msg : null; - $required = isset($field->required) ? $field->required : true; - - $value = null; - switch ($type) { - case "post": - $value = $this->getPostField($name, $required); - break; - case "query": - $value = $this->getQueryField($name, $required); - break; - default: - throw new InternalServerException("Unknown parameter type '$type'"); + // use a method specialized for formats if there is a format available + $format = MetaFormatHelper::extractFormatFromAttribute($reflection); + if ($format !== null) { + $this->processParamsFormat($format); + return; + } + + // otherwise use a method for loose parameters + $paramData = MetaFormatHelper::extractRequestParamData($reflection); + $this->processParamsLoose($paramData); + } + + private function processParamsLoose(array $paramData) + { + // validate each param + foreach ($paramData as $param) { + ///TODO: path parameters are not checked yet + if ($param->type == Type::Path) { + continue; } - if ($validationRule !== null && $value !== null) { - $value = $this->validateValue($name, $value, $validationRule, $msg); + $paramValue = $this->getValueFromParamData($param); + + // this throws when it does not conform + $param->conformsToDefinition($paramValue); + } + } + + private function processParamsFormat(string $format) + { + // get the parsed attribute data from the format fields + $formatToFieldDefinitionsMap = FormatCache::getFormatToFieldDefinitionsMap(); + if (!array_key_exists($format, $formatToFieldDefinitionsMap)) { + throw new InternalServerException("The format $format is not defined."); + } + + // maps field names to their attribute data + $nameToFieldDefinitionsMap = $formatToFieldDefinitionsMap[$format]; + + ///TODO: handle nested MetaFormat creation + $formatInstance = MetaFormatHelper::createFormatInstance($format); + foreach ($nameToFieldDefinitionsMap as $fieldName => $requestParamData) { + ///TODO: path parameters are not checked yet + if ($requestParamData->type == Type::Path) { + continue; } - $this->parameters->$name = $value; + $value = $this->getValueFromParamData($requestParamData); + + // this throws if the value is invalid + $formatInstance->checkedAssign($fieldName, $value); + } + + // validate structural constraints + if (!$formatInstance->validateStructure()) { + throw new BadRequestException("All request fields are valid but additional structural constraints failed."); + } + + $this->requestFormatInstance = $formatInstance; + } + + /** + * Calls either getPostField or getQueryField based on the provided metadata. + * @param \App\Helpers\MetaFormats\RequestParamData $paramData Metadata of the request parameter. + * @throws \App\Exceptions\InternalServerException Thrown when an unexpected parameter location was set. + * @return mixed Returns the value from the request. + */ + private function getValueFromParamData(RequestParamData $paramData): mixed + { + switch ($paramData->type) { + case Type::Post: + return $this->getPostField($paramData->name, required: $paramData->required); + case Type::Query: + return $this->getQueryField($paramData->name, required: $paramData->required); + default: + throw new InternalServerException("Unknown parameter type: {$paramData->type->name}"); } } @@ -268,26 +327,6 @@ private function getQueryField($param, $required = true) return $value; } - private function validateValue($param, $value, $validationRule, $msg = null) - { - foreach (["int", "integer"] as $rule) { - if ($validationRule === $rule || str_starts_with($validationRule, $rule . ":")) { - throw new LogicException("Validation rule '$validationRule' will not work for request parameters"); - } - } - - $value = Validators::preprocessValue($value, $validationRule); - if (Validators::is($value, $validationRule) === false) { - throw new InvalidArgumentException( - $param, - $msg ?? "The value '$value' does not match validation rule '$validationRule'" - . " - for more information check the documentation of Nette\\Utils\\Validators" - ); - } - - return $value; - } - protected function logUserAction($code = IResponse::S200_OK) { if ($this->getUser()->isLoggedIn()) { diff --git a/app/commands/MetaConverter.php b/app/commands/MetaConverter.php new file mode 100644 index 000000000..3f16326cc --- /dev/null +++ b/app/commands/MetaConverter.php @@ -0,0 +1,69 @@ +setName(self::$defaultName)->setDescription( + 'Convert endpoint parameter annotations to attributes..' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->generatePresenters(); + return Command::SUCCESS; + } + + public function generatePresenters() + { + $inDir = __DIR__ . "/../V1Module/presenters"; + $outDir = __DIR__ . "/../V1Module/presenters2"; + + // create output folder + if (!is_dir($outDir)) { + mkdir($outDir); + + // copy base subfolder + $inBaseDir = $inDir . "/base"; + $outBaseDir = $outDir . "/base"; + mkdir($outBaseDir); + $baseFilenames = scandir($inBaseDir); + foreach ($baseFilenames as $filename) { + if (!str_ends_with($filename, ".php")) { + continue; + } + + copy($inBaseDir . "/" . $filename, $outBaseDir . "/" . $filename); + } + } + + // copy and convert Presenters + $filenames = scandir($inDir); + foreach ($filenames as $filename) { + if (!str_ends_with($filename, "Presenter.php")) { + continue; + } + + $filepath = $inDir . "/" . $filename; + $newContent = AnnotationToAttributeConverter::convertFile($filepath); + $newFile = fopen($outDir . "/" . $filename, "w"); + fwrite($newFile, $newContent); + fclose($newFile); + } + } +} diff --git a/app/commands/SwaggerAnnotator.php b/app/commands/SwaggerAnnotator.php index 1c9c9321b..5f052d536 100644 --- a/app/commands/SwaggerAnnotator.php +++ b/app/commands/SwaggerAnnotator.php @@ -21,7 +21,6 @@ class SwaggerAnnotator extends Command { protected static $defaultName = 'swagger:annotate'; - private static $presenterNamespace = 'App\V1Module\Presenters\\'; private static $autogeneratedAnnotationFilePath = 'app/V1Module/presenters/_autogenerated_annotations_temp.php'; protected function configure(): void @@ -41,18 +40,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int $fileBuilder->startClass('__Autogenerated_Annotation_Controller__', '1.0', 'ReCodEx API'); // get all routes of the api - $routes = $this->getRoutes(); - foreach ($routes as $routeObj) { - // extract class and method names of the endpoint - $metadata = $this->extractMetadata($routeObj); - $route = $this->extractRoute($routeObj); - $className = self::$presenterNamespace . $metadata['class']; - + $routesMetadata = AnnotationHelper::getRoutesMetadata(); + foreach ($routesMetadata as $route) { // extract data from the existing annotations - $annotationData = AnnotationHelper::extractAnnotationData($className, $metadata['method'], $route); + $annotationData = AnnotationHelper::extractAttributeData( + $route["class"], + $route['method'], + ); // add an empty method to the file with the transpiled annotations - $fileBuilder->addAnnotatedMethod($metadata['method'], $annotationData->toSwaggerAnnotations($route)); + $fileBuilder->addAnnotatedMethod( + $route['method'], + $annotationData->toSwaggerAnnotations($route["route"]) + ); } $fileBuilder->endClass(); @@ -63,97 +63,4 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } } - - /** - * Finds all route objects of the API - * @return array Returns an array of all found route objects. - */ - private function getRoutes(): array - { - $router = \App\V1Module\RouterFactory::createRouter(); - - // find all route object using a queue - $queue = [$router]; - $routes = []; - while (count($queue) != 0) { - $cursor = array_shift($queue); - - if ($cursor instanceof RouteList) { - foreach ($cursor->getRouters() as $item) { - // lists contain routes or nested lists - if ($item instanceof RouteList) { - array_push($queue, $item); - } else { - // the first route is special and holds no useful information for annotation - if (get_parent_class($item) !== MethodRoute::class) { - continue; - } - - $routes[] = $this->getPropertyValue($item, "route"); - } - } - } - } - - return $routes; - } - - /** - * Extracts the route string from a route object. Replaces '<..>' in the route with '{...}'. - * @param mixed $routeObj - */ - private function extractRoute($routeObj): string - { - $mask = self::getPropertyValue($routeObj, "mask"); - - // sample: replaces '/users/' with '/users/{id}' - $mask = str_replace(["<", ">"], ["{", "}"], $mask); - return "/" . $mask; - } - - /** - * Extracts the class and method names of the endpoint handler. - * @param mixed $routeObj The route object representing the endpoint. - * @return string[] Returns a dictionary [ "class" => ..., "method" => ...] - */ - private function extractMetadata($routeObj) - { - $metadata = self::getPropertyValue($routeObj, "metadata"); - $presenter = $metadata["presenter"]["value"]; - $action = $metadata["action"]["value"]; - - // if the name is empty, the method will be called 'actionDefault' - if ($action === null) { - $action = "default"; - } - - return [ - "class" => $presenter . "Presenter", - "method" => "action" . ucfirst($action), - ]; - } - - /** - * Helper function that can extract a property value from an arbitrary object where - * the property can be private. - * @param mixed $object The object to extract from. - * @param string $propertyName The name of the property. - * @return mixed Returns the value of the property. - */ - private static function getPropertyValue($object, string $propertyName): mixed - { - $class = new ReflectionClass($object); - - do { - try { - $property = $class->getProperty($propertyName); - } catch (ReflectionException $exception) { - $class = $class->getParentClass(); - $property = null; - } - } while ($property === null && $class !== null); - - $property->setAccessible(true); - return $property->getValue($object); - } } diff --git a/app/config/config.neon b/app/config/config.neon index 984f0080d..3c8b17da3 100644 --- a/app/config/config.neon +++ b/app/config/config.neon @@ -320,6 +320,7 @@ services: - App\Console\AsyncJobsUpkeep(%async.upkeep%) - App\Console\GeneralStatsNotification - App\Console\ExportDatabase + - App\Console\MetaConverter - App\Console\GenerateSwagger - App\Console\SwaggerAnnotator - App\Console\CleanupLocalizedTexts diff --git a/app/helpers/MetaFormats/AnnotationConversion/AnnotationToAttributeConverter.php b/app/helpers/MetaFormats/AnnotationConversion/AnnotationToAttributeConverter.php new file mode 100644 index 000000000..516bfbc3e --- /dev/null +++ b/app/helpers/MetaFormats/AnnotationConversion/AnnotationToAttributeConverter.php @@ -0,0 +1,63 @@ + 3 && str_starts_with($line, "use")) { + // add usings for attributes + foreach ($paramAttributeClasses as $class) { + $lines[] = "use {$class};"; + } + $lines[] = "use {$paramTypeClass};"; + foreach (Utils::getValidatorNames() as $validator) { + $lines[] = "use App\\Helpers\\MetaFormats\\Validators\\{$validator};"; + } + // write the detected line (the first detected "use" line) + $lines[] = $line; + $usingsAdded = true; + // detected an attribute line placeholder, increment the counter and remove the line + } elseif (str_contains($line, NetteAnnotationConverter::$attributePlaceholder)) { + $netteAttributeLinesCount++; + // detected the end of the comment block "*/", flush attribute lines + } elseif (trim($line) === "*/") { + $lines[] = $line; + for ($i = 0; $i < $netteAttributeLinesCount; $i++) { + $lines[] = NetteAnnotationConverter::convertCapturesToAttributeString($netteCapturesList[$i]); + } + + // remove the captures used in this endpoint + $netteCapturesList = array_slice($netteCapturesList, $netteAttributeLinesCount); + // reset the counters for the next detected endpoint + $netteAttributeLinesCount = 0; + } else { + $lines[] = $line; + } + } + + return implode("\n", $lines); + } +} diff --git a/app/helpers/MetaFormats/AnnotationConversion/NetteAnnotationConverter.php b/app/helpers/MetaFormats/AnnotationConversion/NetteAnnotationConverter.php new file mode 100644 index 000000000..313835907 --- /dev/null +++ b/app/helpers/MetaFormats/AnnotationConversion/NetteAnnotationConverter.php @@ -0,0 +1,270 @@ +#nette#"; + + /** + * @var array Maps @Param validation fields to validator classes. + */ + private static array $netteValidationToValidatorClassDictionary = [ + "email" => VEmail::class, + // there is one occurrence of this + "email:1.." => VEmail::class, + "numericint" => VInt::class, + "integer" => VInt::class, + "bool" => VBool::class, + "boolean" => VBool::class, + "array" => VArray::class, + "list" => VArray::class, + "timestamp" => VTimestamp::class, + "numeric" => VDouble::class, + "mixed" => VMixed::class, + ]; + + + /** + * Replaces "@Param" annotations with placeholders and extracts its data. + * @param string $fileContent The file content to be replaced. + * @return array{captures: array, contentWithPlaceholders: string} Returns the content with placeholders and the + * extracted data. + */ + public static function regexReplaceAnnotations(string $fileContent) + { + // Array that contains parentheses builders of all future generated attributes. + // Filled dynamically with the preg_replace_callback callback. + $captures = []; + + $contentWithPlaceholders = preg_replace_callback( + self::$paramRegex, + function ($matches) use (&$captures) { + return self::regexCaptureToAttributeCallback($matches, $captures); + }, + $fileContent, + flags: PREG_UNMATCHED_AS_NULL + ); + + return [ + "contentWithPlaceholders" => $contentWithPlaceholders, + "captures" => $captures, + ]; + } + + /** + * Converts regex parameter captures to an attribute string. + * @param array $captures Regex parameter captures. + * @return string Returns the attribute string. + */ + public static function convertCapturesToAttributeString(array $captures) + { + + $annotationParameters = NetteAnnotationConverter::convertCapturesToDictionary($captures); + $paramAttributeClass = Utils::getAttributeClassFromString($annotationParameters["type"]); + $parenthesesBuilder = NetteAnnotationConverter::convertRegexCapturesToParenthesesBuilder($annotationParameters); + $attributeLine = " #[{$paramAttributeClass}{$parenthesesBuilder->toString()}]"; + // change to multiline if the line is too long + if (strlen($attributeLine) > 120) { + $attributeLine = " #[{$paramAttributeClass}{$parenthesesBuilder->toMultilineString(4)}]"; + } + return $attributeLine; + } + + /** + * Converts regex parameter captures into a dictionary. + * @param array $captures The regex captures. + * @throws \App\Exceptions\InternalServerException + * @return array Returns a dictionary with field names as keys pointing to values. + */ + private static function convertCapturesToDictionary(array $captures) + { + // convert the string assignments in $captures to a dictionary + $annotationParameters = []; + // the first element is the matched string + for ($i = 1; $i < count($captures); $i++) { + $capture = $captures[$i]; + if ($capture === null) { + continue; + } + + // the regex extracts the key as the first capture, and the value as the second or third (depends + // whether the value is enclosed in double quotes) + $parseResult = preg_match('/([a-z]+)=(?:(?:"(.+?)")|(?:(.+)))/', $capture, $tokens, PREG_UNMATCHED_AS_NULL); + if ($parseResult !== 1) { + throw new InternalServerException("Unexpected assignment format: $capture"); + } + + $key = $tokens[1]; + $value = $tokens[2] ?? $tokens[3]; + $annotationParameters[$key] = $value; + } + + return $annotationParameters; + } + + /** + * Used by preg_replace_callback to replace "@Param" annotation captures with placeholder strings to mark the + * lines for future replacement. Additionally stores the captures into an output array. + * @param array $captures An array of captures, with empty captures as NULL (PREG_UNMATCHED_AS_NULL flag). + * @param array $capturesList An output list for captures. + * @return string Returns a placeholder. + */ + private static function regexCaptureToAttributeCallback(array $captures, array &$capturesList) + { + $capturesList[] = $captures; + return self::$attributePlaceholder; + } + + /** + * Converts annotation validation values (such as "string:1..255") to Validator construction + * strings (such as "new VString(1, 255)"). + * @param string $validation The annotation validation string. + * @return string Returns the object construction string. + */ + private static function convertAnnotationValidationToValidatorString(string $validation): string + { + if (str_starts_with($validation, "string")) { + $stringValidator = Utils::shortenClass(VString::class); + + // handle string length constraints, such as "string:1..255" + $prefixLength = strlen("string"); + if (strlen($validation) > $prefixLength) { + // the 'string' prefix needs to be followed with a colon + if ($validation[$prefixLength] !== ":") { + throw new InternalServerException("Unknown string validation format: $validation"); + } + // omit the 'string:' section + $suffix = substr($validation, $prefixLength + 1); + + // special case for uuids + if ($suffix === "36") { + return "new " . Utils::shortenClass(VUuid::class) . "()"; + } + + // capture the two bounding numbers and the double dot in strings of + // types "1..255", "..255", "1..", or "255" + if (preg_match("/([0-9]*)(..)?([0-9]+)?/", $suffix, $matches, PREG_UNMATCHED_AS_NULL) !== 1) { + throw new InternalServerException("Unknown string validation format: $validation"); + } + + // type "255", exact match + if ($matches[2] == null) { + return "new {$stringValidator}({$matches[1]}, {$matches[1]})"; + // type "1..255" + } elseif ($matches[1] != null && $matches[3] !== null) { + return "new {$stringValidator}({$matches[1]}, {$matches[3]})"; + // type "..255" + } elseif ($matches[1] == null) { + return "new {$stringValidator}(0, {$matches[3]})"; + // type "1.." + } elseif ($matches[3] == null) { + return "new {$stringValidator}({$matches[1]})"; + } + + throw new InternalServerException("Unknown string validation format: $validation"); + } + + return "new {$stringValidator}()"; + } + + // non-string validation rules do not have parameters, so they can be converted directly + if (!array_key_exists($validation, self::$netteValidationToValidatorClassDictionary)) { + throw new InternalServerException("Unknown validation rule: $validation"); + } + $validatorClass = self::$netteValidationToValidatorClassDictionary[$validation]; + + return "new " . Utils::shortenClass($validatorClass) . "()"; + } + + /** + * Convers a parameter dictionary into an attribute string builder. + * @param array $annotationParameters An associative array with a subset of the following keys: + * name, validation, description, required, nullable. + * @throws \App\Exceptions\InternalServerException + * @return ParenthesesBuilder A string builder used to build the final attribute string. + */ + public static function convertRegexCapturesToParenthesesBuilder(array $annotationParameters) + { + // serialize the parameters to an attribute + $parenthesesBuilder = new ParenthesesBuilder(); + + // add name + if (!array_key_exists("name", $annotationParameters)) { + throw new InternalServerException("Missing name parameter."); + } + $parenthesesBuilder->addValue("\"{$annotationParameters["name"]}\""); + + $nullable = false; + // replace missing validations with placeholder validations + if (!array_key_exists("validation", $annotationParameters)) { + $annotationParameters["validation"] = "mixed"; + // missing validations imply nullability + $nullable = true; + } + $validation = $annotationParameters["validation"]; + + // check nullability, it is either in the validation string, or set explicitly + // validation strings contain the 'null' qualifier always at the end of the string. + $nullabilitySuffix = "|null"; + if (str_ends_with($validation, $nullabilitySuffix)) { + // remove the '|null' + $validation = substr($validation, 0, -strlen($nullabilitySuffix)); + $nullable = true; + // check for explicit nullability + } elseif (array_key_exists("nullable", $annotationParameters)) { + // if it is explicitly not nullable but at the same time has to be nullable due to another factor, + // make it nullable (the other factor can be missing validation) + $nullable |= $annotationParameters["nullable"] === "true"; + } + + // this will always produce a single validator (the annotations do not contain multiple validation fields) + $validator = self::convertAnnotationValidationToValidatorString($validation); + $parenthesesBuilder->addValue(value: $validator); + + if (array_key_exists("description", $annotationParameters)) { + $description = $annotationParameters["description"]; + // escape all quotes and dollar signs + $description = str_replace("\"", "\\\"", $description); + $description = str_replace("$", "\\$", $description); + $parenthesesBuilder->addValue(value: "\"{$description}\""); + } + + if (array_key_exists("required", $annotationParameters)) { + $parenthesesBuilder->addValue("required: " . $annotationParameters["required"]); + } + + if ($nullable) { + $parenthesesBuilder->addValue("nullable: true"); + } + + return $parenthesesBuilder; + } +} diff --git a/app/helpers/MetaFormats/AnnotationConversion/StandardAnnotationConverter.php b/app/helpers/MetaFormats/AnnotationConversion/StandardAnnotationConverter.php new file mode 100644 index 000000000..a2aa61e0a --- /dev/null +++ b/app/helpers/MetaFormats/AnnotationConversion/StandardAnnotationConverter.php @@ -0,0 +1,194 @@ +getStartLine() - 1; + $endpoint["endLine"] = $reflectionMethod->getEndLine() - 1; + } + + // sort endpoints based on position in the file (so that the file preprocessing can be done top-down) + $startLines = array_column($endpoints, "startLine"); + array_multisort($startLines, SORT_ASC, $endpoints); + + // get file lines + $content = file_get_contents($path); + $lines = Utils::fileStringToLines($content); + + // creates a list of replacement annotation blocks and their extends, keyed by original annotation start lines + $annotationReplacements = self::convertEndpointAnnotations($endpoints, $lines); + + // replace original annotations with the new ones + $newLines = []; + for ($i = 0; $i < count($lines); $i++) { + // copy non-annotation lines + if (!array_key_exists($i, $annotationReplacements)) { + $newLines[] = $lines[$i]; + continue; + } + + // add new annotations + foreach ($annotationReplacements[$i]["annotations"] as $replacementLine) { + $newLines[] = $replacementLine; + } + // move $i to the original annotation end line (skip original annotations) + $i = $annotationReplacements[$i]["originalAnnotationEndLine"]; + } + + return implode("\n", $newLines); + } + + /** + * Converts endpoint annotations to annotations with parameter attributes. + * @param array $endpoints Endpoint method metadata sorted by line number. + * @param array $lines Lines of the file to be converted. + * @throws \App\Exceptions\InternalServerException + * @return array A list of dictionaries containing the new annotation lines and the end line + * of the original annotations. + */ + private static function convertEndpointAnnotations(array $endpoints, array $lines): array + { + $annotationReplacements = []; + foreach ($endpoints as $endpoint) { + // get info about endpoint parameters and their types + $annotationData = AnnotationHelper::extractStandardAnnotationData( + $endpoint["class"], + $endpoint["method"], + $endpoint["route"] + ); + + // find start and end lines of method annotations + $annotationEndLine = $endpoint["startLine"] - 1; + $annotationStartLine = -1; + for ($i = $annotationEndLine - 1; $i >= 0; $i--) { + if (str_contains($lines[$i], "/**")) { + $annotationStartLine = $i; + break; + } + } + if ($annotationStartLine == -1) { + throw new InternalServerException("Could not find annotation start line"); + } + + // get all annotation lines for the endoint + $annotationLines = array_slice($lines, $annotationStartLine, $annotationEndLine - $annotationStartLine + 1); + $params = $annotationData->getAllParams(); + + foreach ($params as $param) { + // matches the line containing the parameter name with word boundaries + $paramLineRegex = "/\\$\\b" . $param->name . "\\b/"; + $lineIdx = -1; + for ($i = 0; $i < count($annotationLines); $i++) { + if (preg_match($paramLineRegex, $annotationLines[$i]) === 1) { + $lineIdx = $i; + break; + } + } + + // the endpoint is missing the annotation for the parameter, skip the parameter + if ($lineIdx == -1) { + continue; + } + + // length of the param annotation in lines + $paramAnnotationLength = 1; + // matches lines starting with an asterisks not continued by the @ symbol + $paramContinuationRegex = "/\h*\*\h+[^@]/"; + // find out how long the parameter annotation is + for ($i = $lineIdx + 1; $i < count($annotationLines); $i++) { + if (preg_match($paramContinuationRegex, $annotationLines[$i]) === 1) { + $paramAnnotationLength += 1; + } else { + break; + } + } + + // remove param annotations + array_splice($annotationLines, $lineIdx, $paramAnnotationLength); + } + + // crate an attribute from each parameter + foreach ($params as $param) { + // append the attribute line to the existing annotations + $annotationLines[] = self::getAttributeLineFromMetadata($param); + } + + $annotationReplacements[$annotationStartLine] = [ + "annotations" => $annotationLines, + "originalAnnotationEndLine" => $annotationEndLine, + ]; + } + + return $annotationReplacements; + } + + /** + * Converts parameter metadata into an attribute string. + * @param \App\Helpers\Swagger\AnnotationParameterData $param The parameter metadata. + * @return string The attribute string. + */ + private static function getAttributeLineFromMetadata(AnnotationParameterData $param): string + { + // convert metadata to nette regex capture dictionary + $data = [ + "name" => $param->name, + "validation" => $param->swaggerType, + "type" => $param->location, + "required" => ($param->required ? "true" : "false"), + "nullable" => ($param->nullable ? "true" : "false"), + ]; + if ($param->description != null) { + $data["description"] = $param->description; + } + + $builder = NetteAnnotationConverter::convertRegexCapturesToParenthesesBuilder($data); + $paramAttributeClass = Utils::getAttributeClassFromString($data["type"]); + $attributeLine = " #[{$paramAttributeClass}{$builder->toString()}]"; + // change to multiline if the line is too long + if (strlen($attributeLine) > 120) { + $attributeLine = " #[{$paramAttributeClass}{$builder->toMultilineString(4)}]"; + } + + return $attributeLine; + } +} diff --git a/app/helpers/MetaFormats/AnnotationConversion/Utils.php b/app/helpers/MetaFormats/AnnotationConversion/Utils.php new file mode 100644 index 000000000..ac83a6a21 --- /dev/null +++ b/app/helpers/MetaFormats/AnnotationConversion/Utils.php @@ -0,0 +1,98 @@ + Post::class, + "query" => Query::class, + "path" => Path::class, + ]; + + /** + * Converts a fully qualified class name to a class name without namespace prefixes. + * @param string $className Fully qualified class name, such + * as "App\Helpers\MetaFormats\AnnotationConversion\Utils". + * @return string Class name without namespace prefixes, such as "Utils". + */ + public static function shortenClass(string $className) + { + $tokens = explode("\\", $className); + return end($tokens); + } + + /** + * Splits a string into lines. + * @param string $fileContent The string to be split. + * @throws \App\Exceptions\InternalServerException Thrown when the string cannot be split. + * @return array The lines of the string. + */ + public static function fileStringToLines(string $fileContent): array + { + $lines = preg_split("/((\r?\n)|(\r\n?))/", $fileContent); + if ($lines == false) { + throw new InternalServerException("File content cannot be split into lines"); + } + return $lines; + } + + /** + * @return string[] Returns an array of Validator class names (without the namespace). + */ + public static function getValidatorNames() + { + $dir = __DIR__ . "/../Validators"; + $baseFilenames = scandir($dir); + $classNames = []; + foreach ($baseFilenames as $filename) { + if (!str_ends_with($filename, ".php")) { + continue; + } + + // remove the ".php" suffix + $className = substr($filename, 0, -4); + $classNames[] = $className; + } + return $classNames; + } + + public static function getPresenterNamespace() + { + // extract presenter namespace from BasePresenter + $namespaceTokens = explode("\\", BasePresenter::class); + $namespace = implode("\\", array_slice($namespaceTokens, 0, count($namespaceTokens) - 1)); + return $namespace; + } + + /** + * Returns the attribute class name (without namespace) matching the input parameter location string. + * @param string $type The location type of the parameter (path, query, post). + * @throws \App\Exceptions\InternalServerException Thrown when an unexpected type is provided. + * @return string Returns the attribute class name matching the parameter location type. + */ + public static function getAttributeClassFromString(string $type) + { + if (!array_key_exists($type, self::$paramLocationToAttributeClassDictionary)) { + throw new InternalServerException("Unsupported parameter location: $type"); + } + + $className = self::$paramLocationToAttributeClassDictionary[$type]; + return self::shortenClass($className); + } + + /** + * @return array Returns all parameter attribute class names (including namespace). + */ + public static function getParamAttributeClassNames() + { + return array_values(self::$paramLocationToAttributeClassDictionary); + } +} diff --git a/app/helpers/MetaFormats/Attributes/FPath.php b/app/helpers/MetaFormats/Attributes/FPath.php new file mode 100644 index 000000000..e4a6c4b9a --- /dev/null +++ b/app/helpers/MetaFormats/Attributes/FPath.php @@ -0,0 +1,28 @@ +class = $class; + } +} diff --git a/app/helpers/MetaFormats/Attributes/FormatParameterAttribute.php b/app/helpers/MetaFormats/Attributes/FormatParameterAttribute.php new file mode 100644 index 000000000..9f34d53c6 --- /dev/null +++ b/app/helpers/MetaFormats/Attributes/FormatParameterAttribute.php @@ -0,0 +1,54 @@ +type = $type; + $this->description = $description; + $this->required = $required; + $this->nullable = $nullable; + + // assign validators + if ($validators == null) { + throw new InternalServerException("Parameter Attribute validators are mandatory."); + } + if (!is_array($validators)) { + $this->validators = [ $validators ]; + } else { + if (count($validators) == 0) { + throw new InternalServerException("Parameter Attribute validators are mandatory."); + } + $this->validators = $validators; + } + } +} diff --git a/app/helpers/MetaFormats/Attributes/Param.php b/app/helpers/MetaFormats/Attributes/Param.php new file mode 100644 index 000000000..636370309 --- /dev/null +++ b/app/helpers/MetaFormats/Attributes/Param.php @@ -0,0 +1,35 @@ +paramName = $name; + } +} diff --git a/app/helpers/MetaFormats/Attributes/Path.php b/app/helpers/MetaFormats/Attributes/Path.php new file mode 100644 index 000000000..7df7cca1e --- /dev/null +++ b/app/helpers/MetaFormats/Attributes/Path.php @@ -0,0 +1,30 @@ + => [ => RequestParamData, ...], ...] + * mapping formats to their fields and field metadata. + */ + public static function getFormatToFieldDefinitionsMap(): array + { + if (self::$formatToFieldFormatsMap == null) { + self::$formatToFieldFormatsMap = []; + $formatNames = self::getFormatNames(); + foreach ($formatNames as $format) { + self::$formatToFieldFormatsMap[$format] = MetaFormatHelper::createNameToFieldDefinitionsMap($format); + } + } + return self::$formatToFieldFormatsMap; + } + + /** + * @return array Returns an array of all defined formats. + */ + public static function getFormatNames(): array + { + if (self::$formatNames == null) { + self::$formatNames = MetaFormatHelper::createFormatNamesArray(); + } + return self::$formatNames; + } + + /** + * @return array Returns a hash set of all defined formats (actually a dictionary with arbitrary values). + */ + public static function getFormatNamesHashSet(): array + { + if (self::$formatNamesHashSet == null) { + $formatNames = self::getFormatNames(); + self::$formatNamesHashSet = []; + foreach ($formatNames as $formatName) { + self::$formatNamesHashSet[$formatName] = true; + } + } + return self::$formatNamesHashSet; + } + + public static function formatExists(string $format): bool + { + return array_key_exists($format, self::getFormatNamesHashSet()); + } + + /** + * Fetches field metadata for the given format. + * @param string $format The name of the format. + * @throws \App\Exceptions\InternalServerException Thrown when the format is corrupted. + * @return array Returns a dictionary of field names to RequestParamData. + */ + public static function getFieldDefinitions(string $format) + { + if (!self::formatExists($format)) { + throw new InternalServerException("The class $format does not have a format definition."); + } + + $formatToFieldFormatsMap = self::getFormatToFieldDefinitionsMap(); + if (!array_key_exists($format, $formatToFieldFormatsMap)) { + throw new InternalServerException("The format $format does not have a field format definition."); + } + + return $formatToFieldFormatsMap[$format]; + } +} diff --git a/app/helpers/MetaFormats/FormatDefinitions/UserFormat.php b/app/helpers/MetaFormats/FormatDefinitions/UserFormat.php new file mode 100644 index 000000000..d400a9097 --- /dev/null +++ b/app/helpers/MetaFormats/FormatDefinitions/UserFormat.php @@ -0,0 +1,44 @@ +conformsToDefinition($value); + } + + /** + * Tries to assign a value to a field. If the value does not conform to the field format, an exception is thrown. + * The exception details why the value does not conform to the format. + * @param string $fieldName The name of the field. + * @param mixed $value The value to be assigned. + * @throws \App\Exceptions\InternalServerException Thrown when the field was not found. + * @throws \App\Exceptions\InvalidArgumentException Thrown when the value is not assignable. + */ + public function checkedAssign(string $fieldName, mixed $value) + { + $this->checkIfAssignable($fieldName, $value); + $this->$fieldName = $value; + } + + /** + * Validates the given format. + * @return bool Returns whether the format and all nested formats are valid. + */ + public function validate() + { + // check whether all higher level contracts hold + if (!$this->validateStructure()) { + return false; + } + + // go through all fields and check whether they were assigned properly + $fieldFormats = FormatCache::getFieldDefinitions(get_class($this)); + foreach ($fieldFormats as $fieldName => $fieldFormat) { + if (!$this->checkIfAssignable($fieldName, $this->$fieldName)) { + return false; + } + + // check nested formats recursively + if ($this->$fieldName instanceof MetaFormat && !$this->$fieldName->validate()) { + return false; + } + } + + return true; + } + + /** + * Validates this format. Automatically called by the validate method on all suitable fields. + * Formats might want to override this in case more complex contracts need to be enforced. + * This method should not check the format of nested types. + * @return bool Returns whether the format is valid. + */ + public function validateStructure() + { + // there are no constraints by default + return true; + } +} diff --git a/app/helpers/MetaFormats/MetaFormatHelper.php b/app/helpers/MetaFormats/MetaFormatHelper.php new file mode 100644 index 000000000..84b4d98ce --- /dev/null +++ b/app/helpers/MetaFormats/MetaFormatHelper.php @@ -0,0 +1,192 @@ +getAttributes(Format::class); + if (count($formatAttributes) === 0) { + return null; + } + + $formatAttribute = $formatAttributes[0]->newInstance(); + return $formatAttribute->class; + } + + /** + * Extracts all endpoint parameter attributes. + * @param \ReflectionMethod $reflectionMethod The endpoint reflection method. + * @return array Returns an array of parameter attributes. + */ + public static function getEndpointAttributes(ReflectionMethod $reflectionMethod): array + { + $path = $reflectionMethod->getAttributes(name: Path::class); + $query = $reflectionMethod->getAttributes(name: Query::class); + $post = $reflectionMethod->getAttributes(name: Post::class); + $param = $reflectionMethod->getAttributes(name: Param::class); + return array_merge($path, $query, $post, $param); + } + + /** + * Fetches all attributes of a method and extracts the parameter data. + * @param \ReflectionMethod $reflectionMethod The method reflection object. + * @return array Returns an array of RequestParamData objects with the extracted data. + */ + public static function extractRequestParamData(ReflectionMethod $reflectionMethod): array + { + $attrs = self::getEndpointAttributes($reflectionMethod); + $data = []; + foreach ($attrs as $attr) { + $paramAttr = $attr->newInstance(); + $data[] = new RequestParamData( + $paramAttr->type, + $paramAttr->paramName, + $paramAttr->description, + $paramAttr->required, + $paramAttr->validators, + $paramAttr->nullable, + ); + } + + return $data; + } + + /** + * Finds the format attribute of the property and extracts its data. + * @param \ReflectionProperty $reflectionObject The reflection object of the property. + * @throws \App\Exceptions\InternalServerException Thrown when there is not exactly one format attribute. + * @return RequestParamData Returns the data from the attribute. + */ + public static function extractFormatParameterData(ReflectionProperty $reflectionObject): RequestParamData + { + // find all property attributes + $longAttributes = $reflectionObject->getAttributes(FormatParameterAttribute::class); + $pathAttribues = $reflectionObject->getAttributes(FPath::class); + $queryAttributes = $reflectionObject->getAttributes(FQuery::class); + $postAttributes = $reflectionObject->getAttributes(FPost::class); + $requestAttributes = array_merge($longAttributes, $pathAttribues, $queryAttributes, $postAttributes); + + // there should be only one attribute + if (count($requestAttributes) == 0) { + throw new InternalServerException( + "The field {$reflectionObject->name} of " + . "class {$reflectionObject->class} does not have a property attribute." + ); + } + if (count($requestAttributes) > 1) { + throw new InternalServerException( + "The field {$reflectionObject->name} of " + . "class {$reflectionObject->class} has more than one attribute." + ); + } + + $requestAttribute = $requestAttributes[0]->newInstance(); + return new RequestParamData( + $requestAttribute->type, + $reflectionObject->name, + $requestAttribute->description, + $requestAttribute->required, + $requestAttribute->validators, + $requestAttribute->nullable, + ); + } + + /** + * Debug method used to extract all attribute data of a reflection object. + * @param \ReflectionClass|\ReflectionProperty|\ReflectionMethod $reflectionObject The reflection object. + * @return array Returns an array, where each element represents an attribute in top-down order of definition + * in the code. Each element is an instance of the specific attribute. + */ + public static function debugGetAttributes( + ReflectionClass | ReflectionProperty | ReflectionMethod $reflectionObject + ): array { + $requestAttributes = $reflectionObject->getAttributes(); + $data = []; + foreach ($requestAttributes as $attr) { + $data[] = $attr->newInstance(); + } + return $data; + } + + /** + * Parses the format attributes of class fields and returns their metadata. + * @param string $className The name of the class. + * @return array Returns a dictionary with the field name as the key and RequestParamData as the value. + */ + public static function createNameToFieldDefinitionsMap(string $className): array + { + $class = new ReflectionClass(objectOrClass: $className); + $fields = get_class_vars($className); + $formats = []; + foreach ($fields as $fieldName => $value) { + $field = $class->getProperty($fieldName); + $requestParamData = self::extractFormatParameterData($field); + $formats[$fieldName] = $requestParamData; + } + + return $formats; + } + + /** + * Finds all defined formats and returns an array of their names. + */ + public static function createFormatNamesArray() + { + // scan directory of format definitions + $formatFiles = scandir(self::$formatDefinitionFolder); + // filter out only format files ending with 'Format.php' + $formatFiles = array_filter($formatFiles, function ($file) { + return str_ends_with($file, "Format.php"); + }); + $classes = array_map(function (string $file) { + $fileWithoutExtension = substr($file, 0, -4); + return self::$formatDefinitionsNamespace . "\\$fileWithoutExtension"; + }, $formatFiles); + + // formats are just class names + return array_values($classes); + } + + /** + * Creates a MetaFormat instance of the given format. + * @param string $format The name of the format. + * @throws \App\Exceptions\InternalServerException Thrown when the format does not exist. + * @return \App\Helpers\MetaFormats\MetaFormat Returns the constructed MetaFormat instance. + */ + public static function createFormatInstance(string $format): MetaFormat + { + if (!FormatCache::formatExists($format)) { + throw new InternalServerException("The format $format does not exist."); + } + + $instance = new $format(); + return $instance; + } +} diff --git a/app/helpers/MetaFormats/RequestParamData.php b/app/helpers/MetaFormats/RequestParamData.php new file mode 100644 index 000000000..ad1e44349 --- /dev/null +++ b/app/helpers/MetaFormats/RequestParamData.php @@ -0,0 +1,125 @@ +type = $type; + $this->name = $name; + $this->description = $description; + $this->required = $required; + $this->validators = $validators; + $this->nullable = $nullable; + } + + /** + * Checks whether a value meets this definition. If the definition is not met, an exception is thrown. + * The method has no return value. + * @param mixed $value The value to be checked. + * @throws \App\Exceptions\InvalidArgumentException Thrown when the value does not meet the definition. + */ + public function conformsToDefinition(mixed $value) + { + // check if null + if ($value === null) { + // optional parameters can be null + if (!$this->required) { + return; + } + + // required parameters can be null only if explicitly nullable + if (!$this->nullable) { + throw new InvalidArgumentException( + $this->name, + "The parameter is not nullable and thus cannot be null." + ); + } + + // only non null values should be validated + // (validators do not expect null) + return; + } + + // use every provided validator + foreach ($this->validators as $validator) { + if (!$validator->validate($value)) { + $type = $validator::SWAGGER_TYPE; + throw new InvalidArgumentException( + $this->name, + "The provided value did not pass the validation of type '{$type}'." + ); + } + } + } + + private function hasValidators(): bool + { + if (is_array($this->validators)) { + return count($this->validators) > 0; + } + return $this->validators !== null; + } + + /** + * Converts the metadata into metadata used for swagger generation. + * @throws \App\Exceptions\InternalServerException Thrown when the parameter metadata is corrupted. + * @return AnnotationParameterData Return metadata used for swagger generation. + */ + public function toAnnotationParameterData() + { + if (!$this->hasValidators()) { + throw new InternalServerException( + "No validator found for parameter {$this->name}, description: {$this->description}." + ); + } + + // determine swagger type + $nestedArraySwaggerType = null; + $swaggerType = $this->validators[0]::SWAGGER_TYPE; + // extract array element type + if ($this->validators[0] instanceof VArray) { + $nestedArraySwaggerType = $this->validators[0]->getElementSwaggerType(); + } + + // retrieve the example value from the getExampleValue method if present + $exampleValue = null; + if (method_exists(get_class($this->validators[0]), "getExampleValue")) { + $exampleValue = $this->validators[0]->getExampleValue(); + } + + return new AnnotationParameterData( + $swaggerType, + $this->name, + $this->description, + strtolower($this->type->name), + $this->required, + $this->nullable, + $exampleValue, + $nestedArraySwaggerType, + ); + } +} diff --git a/app/helpers/MetaFormats/Type.php b/app/helpers/MetaFormats/Type.php new file mode 100644 index 000000000..32b5f06b9 --- /dev/null +++ b/app/helpers/MetaFormats/Type.php @@ -0,0 +1,15 @@ +nestedValidator = $nestedValidator; + } + + public function getExampleValue() + { + if ($this->nestedValidator !== null && method_exists(get_class($this->nestedValidator), "getExampleValue")) { + return $this->nestedValidator->getExampleValue(); + } + + return null; + } + + /** + * @return string|null Returns the element swagger type. Can be null if the element validator is not set. + */ + public function getElementSwaggerType(): mixed + { + if ($this->nestedValidator === null) { + return null; + } + + return $this->nestedValidator::SWAGGER_TYPE; + } + + public function validate(mixed $value) + { + if (!is_array($value)) { + return false; + } + + // validate all elements if there is a nested validator + if ($this->nestedValidator != null) { + foreach ($value as $element) { + if (!$this->nestedValidator->validate($element)) { + return false; + } + } + } + return true; + } +} diff --git a/app/helpers/MetaFormats/Validators/VBool.php b/app/helpers/MetaFormats/Validators/VBool.php new file mode 100644 index 000000000..55064756e --- /dev/null +++ b/app/helpers/MetaFormats/Validators/VBool.php @@ -0,0 +1,17 @@ +minLength = $minLength; + $this->maxLength = $maxLength; + $this->regex = $regex; + } + + public function getExampleValue() + { + return "text"; + } + + public function validate(mixed $value): bool + { + // do not allow other types + if (!is_string($value)) { + return false; + } + + // check length + $length = strlen($value); + if ($length < $this->minLength) { + return false; + } + if ($this->maxLength !== -1 && $length > $this->maxLength) { + return false; + } + + // check regex + if ($this->regex === null) { + return true; + } + + return preg_match($this->regex, $value) === 1; + } +} diff --git a/app/helpers/MetaFormats/Validators/VTimestamp.php b/app/helpers/MetaFormats/Validators/VTimestamp.php new file mode 100644 index 000000000..7013c61ec --- /dev/null +++ b/app/helpers/MetaFormats/Validators/VTimestamp.php @@ -0,0 +1,14 @@ +httpMethod = $httpMethod; $this->pathParams = $pathParams; $this->queryParams = $queryParams; $this->bodyParams = $bodyParams; + $this->endpointDescription = $endpointDescription; + } + + public function getAllParams(): array + { + return array_merge($this->pathParams, $this->queryParams, $this->bodyParams); } /** @@ -49,7 +57,7 @@ private function getBodyAnnotation(): string | null return null; } - ///TODO: The swagger generator only supports JSON due to the hardcoded mediaType below + // only json is supported due to the media type $head = '@OA\RequestBody(@OA\MediaType(mediaType="application/json",@OA\Schema'; $body = new ParenthesesBuilder(); @@ -71,6 +79,12 @@ public function toSwaggerAnnotations(string $route) $body = new ParenthesesBuilder(); $body->addKeyValue("path", $route); + // add the endpoint description when provided + if ($this->endpointDescription !== null) { + $body->addKeyValue("summary", $this->endpointDescription); + $body->addKeyValue("description", $this->endpointDescription); + } + foreach ($this->pathParams as $pathParam) { $body->addValue($pathParam->toParameterAnnotation()); } diff --git a/app/helpers/Swagger/AnnotationHelper.php b/app/helpers/Swagger/AnnotationHelper.php index 1b2ba8840..ebf7829c5 100644 --- a/app/helpers/Swagger/AnnotationHelper.php +++ b/app/helpers/Swagger/AnnotationHelper.php @@ -2,22 +2,55 @@ namespace App\Helpers\Swagger; +use App\Exceptions\InvalidArgumentException; +use App\Helpers\MetaFormats\MetaFormatHelper; +use App\V1Module\Router\MethodRoute; +use App\V1Module\RouterFactory; use ReflectionClass; +use ReflectionException; use ReflectionMethod; use Exception; +use Nette\Routing\RouteList; /** * Parser that can parse the annotations of existing recodex endpoints. */ class AnnotationHelper { + private static $nullableSuffix = '|null'; + private static $typeMap = [ + 'bool' => 'boolean', + 'boolean' => 'boolean', + 'array' => 'array', + 'int' => 'integer', + 'integer' => 'integer', + 'float' => 'number', + 'number' => 'number', + 'numeric' => 'number', + 'numericint' => 'integer', + 'timestamp' => 'integer', + 'string' => 'string', + 'unicode' => 'string', + 'email' => 'string', + 'url' => 'string', + 'uri' => 'string', + 'pattern' => null, + 'alnum' => 'string', + 'alpha' => 'string', + 'digit' => 'string', + 'lower' => 'string', + 'upper' => 'string', + ]; + + private static $presenterNamespace = 'App\V1Module\Presenters\\'; + /** * Returns a ReflectionMethod object matching the name of the method and containing class. * @param string $className The name of the containing class. * @param string $methodName The name of the method. * @return \ReflectionMethod Returns the ReflectionMethod object. */ - private static function getMethod(string $className, string $methodName): ReflectionMethod + public static function getMethod(string $className, string $methodName): ReflectionMethod { $class = new ReflectionClass($className); return $class->getMethod($methodName); @@ -39,14 +72,55 @@ private static function extractAnnotationHttpMethod(array $annotations): HttpMet // check if the annotations have an http method foreach ($methods as $methodString => $methodEnum) { - if (in_array($methodString, $annotations)) { - return $methodEnum; + foreach ($annotations as $annotation) { + if (str_starts_with($annotation, $methodString)) { + return $methodEnum; + } } } return null; } + private static function isDatatypeNullable(mixed $annotationType): bool + { + // if the dataType is not specified (it is null), it means that the annotation is not + // complete and defaults to a non nullable string + if ($annotationType === null) { + return false; + } + + // assumes that the typename contains 'null' + if (str_contains($annotationType, "null")) { + return true; + } + + return false; + } + + /** + * Returns the swagger type associated with the annotation data type. + * @return string Returns the name of the swagger type. + */ + private static function getSwaggerType(string $annotationType): string + { + // if the type is not specified, default to a string + $type = 'string'; + $typename = $annotationType; + if ($typename !== null) { + if (self::isDatatypeNullable($annotationType)) { + $typename = substr($typename, 0, -strlen(self::$nullableSuffix)); + } + + if (self::$typeMap[$typename] === null) { + throw new InvalidArgumentException("Error in getSwaggerType: Unknown typename: {$typename}"); + } + + $type = self::$typeMap[$typename]; + } + return $type; + } + /** * Extracts standart doc comments from endpoints, such as '@param string $id An identifier'. * Based on the HTTP route of the endpoint, the extracted param can be identified as either a path or @@ -59,90 +133,75 @@ private static function extractStandardAnnotationParams(array $annotations, stri { $routeParams = self::getRoutePathParamNames($route); + // does not see unannotated query params, but there are not any $params = []; foreach ($annotations as $annotation) { // assumed that all query parameters have a @param annotation if (str_starts_with($annotation, "@param")) { // sample: @param string $id Identifier of the user $tokens = explode(" ", $annotation); - $type = $tokens[1]; + $annotationType = $tokens[1]; // assumed that all names start with $ $name = substr($tokens[2], 1); $description = implode(" ", array_slice($tokens, 3)); + // path params have to be required + $isPathParam = false; // figure out where the parameter is located $location = 'query'; if (in_array($name, $routeParams)) { $location = 'path'; + $isPathParam = true; + // remove the path param from the path param list to detect parameters left behind + // (this happens when the path param does not have an annotation line) + unset($routeParams[array_search($name, $routeParams)]); } - $descriptor = new AnnotationParameterData($type, $name, $description, $location); - $params[] = $descriptor; - } - } - return $params; - } + $swaggerType = self::getSwaggerType($annotationType); + $nullable = self::isDatatypeNullable($annotationType); - /** - * Converts an array of assignment string to an associative array. - * @param array $expressions An array containing values in the following format: 'key="value"'. - * @return array Returns an associative array made from the string array. - */ - private static function stringArrayToAssociativeArray(array $expressions): array - { - $dict = []; - //sample: [ 'name="uiData"', 'validation="array|null"' ] - foreach ($expressions as $expression) { - $tokens = explode('="', $expression); - $name = $tokens[0]; - // remove the '"' at the end - $value = substr($tokens[1], 0, -1); - $dict[$name] = $value; - } - return $dict; - } + // the array element type cannot be determined from standard @param annotations + $nestedArraySwaggerType = null; - /** - * Extracts annotation parameter data from Nette annotations starting with the '@Param' prefix. - * @param array $annotations An array of annotations. - * @return array Returns an array of AnnotationParameterData objects describing the parameters. - */ - private static function extractNetteAnnotationParams(array $annotations): array - { - $bodyParams = []; - $prefix = "@Param"; - foreach ($annotations as $annotation) { - // assumed that all body parameters have a @Param annotation - if (str_starts_with($annotation, $prefix)) { - // sample: @Param(type="post", name="uiData", validation="array|null", - // description="Structured user-specific UI data") - // remove '@Param(' from the start and ')' from the end - $body = substr($annotation, strlen($prefix) + 1, -1); - $tokens = explode(", ", $body); - $values = self::stringArrayToAssociativeArray($tokens); $descriptor = new AnnotationParameterData( - $values["validation"], - $values["name"], - $values["description"], - $values["type"] + $swaggerType, + $name, + $description, + $location, + $isPathParam, + $nullable, + nestedArraySwaggerType: $nestedArraySwaggerType, ); - $bodyParams[] = $descriptor; + $params[] = $descriptor; } } - return $bodyParams; + + // handle path params without annotations + foreach ($routeParams as $pathParam) { + $descriptor = new AnnotationParameterData( + // some type needs to be assigned and string seems reasonable for a param without any info + "string", + $pathParam, + null, + "path", + true, + false, + ); + $params[] = $descriptor; + } + + return $params; } /** - * Returns all method annotation lines as an array. + * Parses an annotation string and returns the lines as an array. * Lines not starting with '@' are assumed to be continuations of a parent line starting with @ (or the initial * line not starting with '@') and are merged into a single line. - * @param string $className The name of the containing class. - * @param string $methodName The name of the method. + * @param string $annotations The annotation string. * @return array Returns an array of the annotation lines. */ - private static function getMethodAnnotations(string $className, string $methodName): array + public static function getAnnotationLines(string $annotations): array { - $annotations = self::getMethod($className, $methodName)->getDocComment(); $lines = preg_split("/\r\n|\n|\r/", $annotations); // trims whitespace and asterisks @@ -162,7 +221,8 @@ private static function getMethodAnnotations(string $className, string $methodNa $line = $lines[$i]; // skip lines not starting with '@' - if ($line[0] !== "@") { + // also do not skip the first description line + if ($i != 0 && $line[0] !== "@") { continue; } @@ -178,6 +238,20 @@ private static function getMethodAnnotations(string $className, string $methodNa return $merged; } + /** + * Returns all method annotation lines as an array. + * Lines not starting with '@' are assumed to be continuations of a parent line starting with @ (or the initial + * line not starting with '@') and are merged into a single line. + * @param string $className The name of the containing class. + * @param string $methodName The name of the method. + * @return array Returns an array of the annotation lines. + */ + public static function getMethodAnnotations(string $className, string $methodName): array + { + $annotations = self::getMethod($className, $methodName)->getDocComment(); + return self::getAnnotationLines($annotations); + } + /** * Extracts strings enclosed by curly brackets. * @param string $route The source string. @@ -191,24 +265,23 @@ private static function getRoutePathParamNames(string $route): array } /** - * Extracts the annotation data of an endpoint. The data contains request parameters based on their type - * and the HTTP method. - * @param string $className The name of the containing class. - * @param string $methodName The name of the endpoint method. - * @param string $route The route to the method. - * @throws Exception Thrown when the parser encounters an unknown parameter location (known locations are - * path, query and post) - * @return \App\Helpers\Swagger\AnnotationData Returns a data object containing the parameters and HTTP method. + * Extracts the annotation description line. + * @param array $annotations The array of annotations. */ - public static function extractAnnotationData(string $className, string $methodName, string $route): AnnotationData + private static function extractAnnotationDescription(array $annotations): ?string { - $methodAnnotations = self::getMethodAnnotations($className, $methodName); - - $httpMethod = self::extractAnnotationHttpMethod($methodAnnotations); - $standardAnnotationParams = self::extractStandardAnnotationParams($methodAnnotations, $route); - $netteAnnotationParams = self::extractNetteAnnotationParams($methodAnnotations); - $params = array_merge($standardAnnotationParams, $netteAnnotationParams); + // it is either the first line (already merged if multiline), or none at all + if (!str_starts_with($annotations[0], "@")) { + return $annotations[0]; + } + return null; + } + private static function annotationParameterDataToAnnotationData( + HttpMethods $method, + array $params, + ?string $description + ): AnnotationData { $pathParams = []; $queryParams = []; $bodyParams = []; @@ -225,9 +298,54 @@ public static function extractAnnotationData(string $className, string $methodNa } } + return new AnnotationData($method, $pathParams, $queryParams, $bodyParams, $description); + } + + /** + * Extracts standard (@param) annotation data of an endpoint. The data contains request parameters based + * on their type and the HTTP method. + * @param string $className The name of the containing class. + * @param string $methodName The name of the endpoint method. + * @param string $route The route to the method. + * @throws Exception Thrown when the parser encounters an unknown parameter location (known locations are + * path, query and post) + * @return \App\Helpers\Swagger\AnnotationData Returns a data object containing the parameters and HTTP method. + */ + public static function extractStandardAnnotationData( + string $className, + string $methodName, + string $route + ): AnnotationData { + $methodAnnotations = self::getMethodAnnotations($className, $methodName); + + $httpMethod = self::extractAnnotationHttpMethod($methodAnnotations); + $params = self::extractStandardAnnotationParams($methodAnnotations, $route); + $description = self::extractAnnotationDescription($methodAnnotations); + + return self::annotationParameterDataToAnnotationData($httpMethod, $params, $description); + } - $data = new AnnotationData($httpMethod, $pathParams, $queryParams, $bodyParams); - return $data; + /** + * Extracts the attribute data of an endpoint. The data contains request parameters based on their type + * and the HTTP method. + * @param string $className The name of the containing class. + * @param string $methodName The name of the endpoint method. + * @throws Exception Thrown when the parser encounters an unknown parameter location (known locations are + * path, query and post) + * @return \App\Helpers\Swagger\AnnotationData Returns a data object containing the parameters and HTTP method. + */ + public static function extractAttributeData(string $className, string $methodName): AnnotationData + { + $methodAnnotations = self::getMethodAnnotations($className, $methodName); + + $httpMethod = self::extractAnnotationHttpMethod($methodAnnotations); + $attributeData = MetaFormatHelper::extractRequestParamData(self::getMethod($className, $methodName)); + $params = array_map(function ($data) { + return $data->toAnnotationParameterData(); + }, $attributeData); + $description = self::extractAnnotationDescription($methodAnnotations); + + return self::annotationParameterDataToAnnotationData($httpMethod, $params, $description); } /** @@ -246,4 +364,113 @@ public static function filterAnnotations(array $annotations, string $type) } return $rows; } + + /** + * Finds all route objects of the API and returns their metadata. + * @return array Returns an array of dictionaries with the keys "route", "class", and "method". + */ + public static function getRoutesMetadata(): array + { + $router = RouterFactory::createRouter(); + + // find all route object using a queue + $queue = [$router]; + $routes = []; + while (count($queue) != 0) { + $cursor = array_shift($queue); + + if ($cursor instanceof RouteList) { + foreach ($cursor->getRouters() as $item) { + // lists contain routes or nested lists + if ($item instanceof RouteList) { + array_push($queue, $item); + } else { + // the first route is special and holds no useful information for annotation + if (get_parent_class($item) !== MethodRoute::class) { + continue; + } + + $routes[] = self::getPropertyValue($item, "route"); + } + } + } + } + + + $routeMetadata = []; + foreach ($routes as $routeObj) { + // extract class and method names of the endpoint + $metadata = self::extractMetadata($routeObj); + $route = self::extractRoute($routeObj); + $className = self::$presenterNamespace . $metadata['class']; + $methodName = $metadata['method']; + + $routeMetadata[] = [ + "route" => $route, + "class" => $className, + "method" => $methodName, + ]; + } + + return $routeMetadata; + } + + /** + * Helper function that can extract a property value from an arbitrary object where + * the property can be private. + * @param mixed $object The object to extract from. + * @param string $propertyName The name of the property. + * @return mixed Returns the value of the property. + */ + public static function getPropertyValue(mixed $object, string $propertyName): mixed + { + $class = new ReflectionClass($object); + + do { + try { + $property = $class->getProperty($propertyName); + } catch (ReflectionException $exception) { + $class = $class->getParentClass(); + $property = null; + } + } while ($property === null && $class !== null); + + $property->setAccessible(true); + return $property->getValue($object); + } + + /** + * Extracts the route string from a route object. Replaces '<..>' in the route with '{...}'. + * @param mixed $routeObj + */ + private static function extractRoute($routeObj): string + { + $mask = AnnotationHelper::getPropertyValue($routeObj, "mask"); + + // sample: replaces '/users/' with '/users/{id}' + $mask = str_replace(["<", ">"], ["{", "}"], $mask); + return "/" . $mask; + } + + /** + * Extracts the class and method names of the endpoint handler. + * @param mixed $routeObj The route object representing the endpoint. + * @return string[] Returns a dictionary [ "class" => ..., "method" => ...] + */ + private static function extractMetadata($routeObj) + { + $metadata = AnnotationHelper::getPropertyValue($routeObj, "metadata"); + $presenter = $metadata["presenter"]["value"]; + $action = $metadata["action"]["value"]; + + // if the name is empty, the method will be called 'actionDefault' + if ($action === null) { + $action = "default"; + } + + return [ + "class" => $presenter . "Presenter", + "method" => "action" . ucfirst($action), + ]; + } } diff --git a/app/helpers/Swagger/AnnotationParameterData.php b/app/helpers/Swagger/AnnotationParameterData.php index d915f32d3..ddd9924f6 100644 --- a/app/helpers/Swagger/AnnotationParameterData.php +++ b/app/helpers/Swagger/AnnotationParameterData.php @@ -2,93 +2,60 @@ namespace App\Helpers\Swagger; +use App\Exceptions\InternalServerException; + /** * Contains data of a single annotation parameter. + * Used for swagger generation. */ class AnnotationParameterData { - public string | null $dataType; + public string $swaggerType; public string $name; - public string | null $description; + public ?string $description; public string $location; - - private static $nullableSuffix = '|null'; - private static $typeMap = [ - 'bool' => 'boolean', - 'boolean' => 'boolean', - 'array' => 'array', - 'int' => 'integer', - 'integer' => 'integer', - 'float' => 'number', - 'number' => 'number', - 'numeric' => 'number', - 'numericint' => 'integer', - 'timestamp' => 'integer', - 'string' => 'string', - 'unicode' => 'string', - 'email' => 'string', - 'url' => 'string', - 'uri' => 'string', - 'pattern' => null, - 'alnum' => 'string', - 'alpha' => 'string', - 'digit' => 'string', - 'lower' => 'string', - 'upper' => 'string', - ]; + public bool $required; + public bool $nullable; + public ?string $example; + public ?string $nestedArraySwaggerType; public function __construct( - string | null $dataType, + string $swaggerType, string $name, - string | null $description, - string $location + ?string $description, + string $location, + bool $required, + bool $nullable, + string $example = null, + string $nestedArraySwaggerType = null, ) { - $this->dataType = $dataType; + $this->swaggerType = $swaggerType; $this->name = $name; $this->description = $description; $this->location = $location; + $this->required = $required; + $this->nullable = $nullable; + $this->example = $example; + $this->nestedArraySwaggerType = $nestedArraySwaggerType; } - private function isDatatypeNullable(): bool + private function addArrayItemsIfArray(string $swaggerType, ParenthesesBuilder $container) { - // if the dataType is not specified (it is null), it means that the annotation is not - // complete and defaults to a non nullable string - if ($this->dataType === null) { - return false; - } - - // assumes that the typename ends with '|null' - if (str_ends_with($this->dataType, self::$nullableSuffix)) { - return true; - } - - return false; - } + if ($swaggerType === "array") { + $itemsHead = "@OA\\Items"; + $items = new ParenthesesBuilder(); - /** - * Returns the swagger type associated with the annotation data type. - * @return string Returns the name of the swagger type. - */ - private function getSwaggerType(): string - { - // if the type is not specified, default to a string - $type = 'string'; - $typename = $this->dataType; - if ($typename !== null) { - if ($this->isDatatypeNullable()) { - $typename = substr($typename, 0, -strlen(self::$nullableSuffix)); + if ($this->nestedArraySwaggerType !== null) { + $items->addKeyValue("type", $this->nestedArraySwaggerType); } - - if (self::$typeMap[$typename] === null) { - ///TODO: Return the commented exception below once the meta-view formats are implemented. - /// This detaults to strings because custom types like 'email' are not supported yet. - return 'string'; + + // add example value + if ($this->example != null) { + $items->addKeyValue("example", $this->example); } - //throw new \InvalidArgumentException("Error in getSwaggerType: Unknown typename: {$typename}"); - - $type = self::$typeMap[$typename]; + + $container->addValue($itemsHead . $items->toString()); } - return $type; } /** @@ -100,7 +67,9 @@ private function generateSchemaAnnotation(): string $head = "@OA\\Schema"; $body = new ParenthesesBuilder(); - $body->addKeyValue("type", $this->getSwaggerType()); + $body->addKeyValue("type", $this->swaggerType); + $this->addArrayItemsIfArray($this->swaggerType, $body); + return $head . $body->toString(); } @@ -111,10 +80,11 @@ public function toParameterAnnotation(): string { $head = "@OA\\Parameter"; $body = new ParenthesesBuilder(); - + $body->addKeyValue("name", $this->name); $body->addKeyValue("in", $this->location); - $body->addKeyValue("required", !$this->isDatatypeNullable()); + $body->addKeyValue("required", $this->required); + if ($this->description !== null) { $body->addKeyValue("description", $this->description); } @@ -133,9 +103,24 @@ public function toPropertyAnnotation(): string $head = "@OA\\Property"; $body = new ParenthesesBuilder(); - ///TODO: Once the meta-view formats are implemented, add support for property nullability here. $body->addKeyValue("property", $this->name); - $body->addKeyValue("type", $this->getSwaggerType()); + $body->addKeyValue("type", $this->swaggerType); + $body->addKeyValue("nullable", $this->nullable); + + if ($this->description !== null) { + $body->addKeyValue("description", $this->description); + } + + // handle arrays + $this->addArrayItemsIfArray($this->swaggerType, $body); + + // add example value + if ($this->swaggerType !== "array") { + if ($this->example != null) { + $body->addKeyValue("example", $this->example); + } + } + return $head . $body->toString(); } } diff --git a/app/helpers/Swagger/ParenthesesBuilder.php b/app/helpers/Swagger/ParenthesesBuilder.php index ef744785f..eab3448c8 100644 --- a/app/helpers/Swagger/ParenthesesBuilder.php +++ b/app/helpers/Swagger/ParenthesesBuilder.php @@ -48,6 +48,23 @@ public function addValue(string $value): ParenthesesBuilder public function toString(): string { - return '(' . implode(',', $this->tokens) . ')'; + return "(" . implode(", ", $this->tokens) . ")"; + } + + private static function spaces(int $count): string + { + return str_repeat(" ", $count); + } + + private const CODEBASE_INDENTATION = 4; + public function toMultilineString(int $initialIndentation): string + { + // do not add indentation to the first line + $str = "(\n"; + foreach ($this->tokens as $token) { + $str .= self::spaces($initialIndentation + self::CODEBASE_INDENTATION) . $token . ",\n"; + } + $str .= self::spaces($initialIndentation) . ")"; + return $str; } } diff --git a/app/model/view/MetaView.php b/app/model/view/MetaView.php new file mode 100644 index 000000000..b4fdc535d --- /dev/null +++ b/app/model/view/MetaView.php @@ -0,0 +1,13 @@ + 'addComment', 'id' => '6b89a6df-f7e8-4c2c-a216-1b7cb4391647'], // mainThread - ['text' => 'some comment text', 'isPrivate' => 'false'] + ['text' => 'some comment text', 'isPrivate' => false] ); $response = $this->presenter->run($request); Assert::type(Nette\Application\Responses\JsonResponse::class, $response); @@ -141,7 +141,7 @@ class TestCommentsPresenter extends Tester\TestCase 'V1:Comments', 'POST', ['action' => 'addComment', 'id' => $assignmentSolution->getId()], - ['text' => 'some comment text', 'isPrivate' => 'false'] + ['text' => 'some comment text', 'isPrivate' => false] ); $response = $this->presenter->run($request); Assert::type(Nette\Application\Responses\JsonResponse::class, $response); @@ -172,7 +172,7 @@ class TestCommentsPresenter extends Tester\TestCase 'V1:Comments', 'POST', ['action' => 'addComment', 'id' => $referenceSolution->getId()], - ['text' => 'some comment text', 'isPrivate' => 'false'] + ['text' => 'some comment text', 'isPrivate' => false] ); $response = $this->presenter->run($request); Assert::type(Nette\Application\Responses\JsonResponse::class, $response); @@ -202,7 +202,7 @@ class TestCommentsPresenter extends Tester\TestCase 'V1:Comments', 'POST', ['action' => 'addComment', 'id' => $assignment->getId()], - ['text' => 'some comment text', 'isPrivate' => 'false'] + ['text' => 'some comment text', 'isPrivate' => false] ); $response = $this->presenter->run($request); Assert::type(Nette\Application\Responses\JsonResponse::class, $response); @@ -226,7 +226,7 @@ class TestCommentsPresenter extends Tester\TestCase 'V1:Comments', 'POST', ['action' => 'addComment', 'id' => '5d45dcd0-50e7-4b2a-a291-cfe4b5fb5cbb'], // dummy thread (nonexist) - ['text' => 'some comment text', 'isPrivate' => 'false'] + ['text' => 'some comment text', 'isPrivate' => false] ); $response = $this->presenter->run($request); Assert::type(Nette\Application\Responses\JsonResponse::class, $response); diff --git a/tests/Presenters/ExercisesPresenter.phpt b/tests/Presenters/ExercisesPresenter.phpt index 5ef1f6296..3da90ca52 100644 --- a/tests/Presenters/ExercisesPresenter.phpt +++ b/tests/Presenters/ExercisesPresenter.phpt @@ -979,7 +979,7 @@ class TestExercisesPresenter extends Tester\TestCase 'V1:Exercises', 'POST', ['action' => 'setArchived', 'id' => $exercise->getId()], - ['archived' => 'true'] + ['archived' => true] ); $this->presenter->exercises->refresh($exercise); @@ -1002,7 +1002,7 @@ class TestExercisesPresenter extends Tester\TestCase 'V1:Exercises', 'POST', ['action' => 'setArchived', 'id' => $exercise->getId()], - ['archived' => 'true'] + ['archived' => true] ); }, ForbiddenRequestException::class @@ -1022,7 +1022,7 @@ class TestExercisesPresenter extends Tester\TestCase 'V1:Exercises', 'POST', ['action' => 'setArchived', 'id' => $exercise->getId()], - ['archived' => 'false'] + ['archived' => false] ); $this->presenter->exercises->refresh($exercise); diff --git a/tests/Presenters/InstancesPresenter.phpt b/tests/Presenters/InstancesPresenter.phpt index 28edbb0d1..68b8e585c 100644 --- a/tests/Presenters/InstancesPresenter.phpt +++ b/tests/Presenters/InstancesPresenter.phpt @@ -97,7 +97,7 @@ class TestInstancesPresenter extends Tester\TestCase 'V1:Instances', 'POST', ['action' => 'createInstance'], - ['name' => 'NIOT', 'description' => 'Just a new instance', 'isOpen' => 'true'] + ['name' => 'NIOT', 'description' => 'Just a new instance', 'isOpen' => true] ); $response = $this->presenter->run($request); Assert::type(Nette\Application\Responses\JsonResponse::class, $response); @@ -143,7 +143,7 @@ class TestInstancesPresenter extends Tester\TestCase 'V1:Instances', 'POST', ['action' => 'createInstance'], - ['name' => 'NIOT', 'description' => 'Just a new instance', 'isOpen' => 'true'] + ['name' => 'NIOT', 'description' => 'Just a new instance', 'isOpen' => true] ); $response = $this->presenter->run($request); $newInstanceId = $response->getPayload()['payload']['id']; @@ -225,7 +225,7 @@ class TestInstancesPresenter extends Tester\TestCase 'V1:Instances', 'POST', ['action' => 'updateLicence', 'licenceId' => $newLicence->getId()], - ['note' => 'Changed description', 'validUntil' => '2020-01-01 13:02:56', 'isValid' => 'false'] + ['note' => 'Changed description', 'validUntil' => '2020-01-01 13:02:56', 'isValid' => false] ); $response = $this->presenter->run($request); Assert::type(Nette\Application\Responses\JsonResponse::class, $response); diff --git a/tests/Presenters/UsersPresenter.phpt b/tests/Presenters/UsersPresenter.phpt index 26f581228..1b9d7467a 100644 --- a/tests/Presenters/UsersPresenter.phpt +++ b/tests/Presenters/UsersPresenter.phpt @@ -830,7 +830,7 @@ class TestUsersPresenter extends Tester\TestCase $this->presenterPath, 'POST', ['action' => 'setAllowed', 'id' => $user->getId()], - ['isAllowed' => 0] + ['isAllowed' => false] ); Assert::same($user->getId(), $payload['id']);