From ff295c552122c6475c638438328c14686cf18418 Mon Sep 17 00:00:00 2001 From: Nicky Gerritsen Date: Fri, 9 Feb 2024 15:17:51 +0100 Subject: [PATCH] Use DTO's for POST/PUT request bodies. --- composer.json | 5 + composer.lock | 102 +++++++++++- webapp/config/packages/framework.yaml | 3 + webapp/config/packages/nelmio_api_doc.yaml | 102 ------------ .../Controller/API/AbstractRestController.php | 6 +- .../API/ClarificationController.php | 38 ++--- .../src/Controller/API/ContestController.php | 50 ++---- webapp/src/Controller/API/GroupController.php | 31 ++-- .../Controller/API/OrganizationController.php | 26 +++- .../src/Controller/API/ProblemController.php | 33 ++-- .../Controller/API/SubmissionController.php | 145 +++--------------- webapp/src/Controller/API/TeamController.php | 32 +++- webapp/src/Controller/API/UserController.php | 52 +++---- .../DataTransferObject/AddOrganization.php | 23 +++ .../src/DataTransferObject/AddSubmission.php | 67 ++++++++ .../DataTransferObject/AddSubmissionFile.php | 15 ++ webapp/src/DataTransferObject/AddTeam.php | 36 +++++ .../DataTransferObject/AddTeamLocation.php | 13 ++ webapp/src/DataTransferObject/AddUser.php | 30 ++++ .../DataTransferObject/ClarificationPost.php | 26 ++++ .../DataTransferObject/ContestProblemPut.php | 22 +++ .../src/DataTransferObject/PatchContest.php | 19 +++ .../DataTransferObject/TeamCategoryPost.php | 24 +++ .../FosRestBundle/FlattenExceptionHandler.php | 7 + .../API/ClarificationControllerTest.php | 10 +- .../API/ContestControllerAdminTest.php | 2 +- .../Controller/API/GroupControllerTest.php | 5 +- .../API/SubmissionControllerTest.php | 18 ++- 28 files changed, 569 insertions(+), 373 deletions(-) create mode 100644 webapp/src/DataTransferObject/AddOrganization.php create mode 100644 webapp/src/DataTransferObject/AddSubmission.php create mode 100644 webapp/src/DataTransferObject/AddSubmissionFile.php create mode 100644 webapp/src/DataTransferObject/AddTeam.php create mode 100644 webapp/src/DataTransferObject/AddTeamLocation.php create mode 100644 webapp/src/DataTransferObject/AddUser.php create mode 100644 webapp/src/DataTransferObject/ClarificationPost.php create mode 100644 webapp/src/DataTransferObject/ContestProblemPut.php create mode 100644 webapp/src/DataTransferObject/PatchContest.php create mode 100644 webapp/src/DataTransferObject/TeamCategoryPost.php diff --git a/composer.json b/composer.json index e060075c086..05495bef825 100644 --- a/composer.json +++ b/composer.json @@ -71,6 +71,8 @@ "mbostock/d3": "^3.5", "nelmio/api-doc-bundle": "^4.11", "novus/nvd3": "^1.8", + "phpdocumentor/reflection-docblock": "^5.3", + "phpstan/phpdoc-parser": "^1.25", "promphp/prometheus_client_php": "^2.6", "ramsey/uuid": "^4.2", "select2/select2": "4.*", @@ -89,9 +91,12 @@ "symfony/intl": "6.4.*", "symfony/mime": "6.4.*", "symfony/monolog-bundle": "^3.8.0", + "symfony/property-access": "6.4.*", + "symfony/property-info": "6.4.*", "symfony/runtime": "6.4.*", "symfony/security-bundle": "6.4.*", "symfony/security-csrf": "6.4.*", + "symfony/serializer": "6.4.*", "symfony/stopwatch": "6.4.*", "symfony/twig-bundle": "6.4.*", "symfony/validator": "6.4.*", diff --git a/composer.lock b/composer.lock index cc13572f259..b4654dcf7ad 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f833613ad9885c067c7cc3a2e60b3a5c", + "content-hash": "46b6797a184b1ae6a2c1bf8f577fb445", "packages": [ { "name": "apalfrey/select2-bootstrap-5-theme", @@ -9280,6 +9280,104 @@ ], "time": "2024-01-23T14:51:35+00:00" }, + { + "name": "symfony/serializer", + "version": "v6.4.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/serializer.git", + "reference": "51a06ee93c4d5ab5b9edaa0635d8b83953e3c14d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/serializer/zipball/51a06ee93c4d5ab5b9edaa0635d8b83953e3c14d", + "reference": "51a06ee93c4d5ab5b9edaa0635d8b83953e3c14d", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "doctrine/annotations": "<1.12", + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/dependency-injection": "<5.4", + "symfony/property-access": "<5.4", + "symfony/property-info": "<5.4.24|>=6,<6.2.11", + "symfony/uid": "<5.4", + "symfony/validator": "<6.4", + "symfony/yaml": "<5.4" + }, + "require-dev": { + "doctrine/annotations": "^1.12|^2", + "phpdocumentor/reflection-docblock": "^3.2|^4.0|^5.0", + "seld/jsonlint": "^1.10", + "symfony/cache": "^5.4|^6.0|^7.0", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/error-handler": "^5.4|^6.0|^7.0", + "symfony/filesystem": "^5.4|^6.0|^7.0", + "symfony/form": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/mime": "^5.4|^6.0|^7.0", + "symfony/property-access": "^5.4.26|^6.3|^7.0", + "symfony/property-info": "^5.4.24|^6.2.11|^7.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^5.4|^6.0|^7.0", + "symfony/validator": "^6.4|^7.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0", + "symfony/var-exporter": "^5.4|^6.0|^7.0", + "symfony/yaml": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Serializer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/serializer/tree/v6.4.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-01-30T08:32:12+00:00" + }, { "name": "symfony/service-contracts", "version": "v3.4.1", @@ -13043,5 +13141,5 @@ "platform-overrides": { "php": "8.1.0" }, - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.6.0" } diff --git a/webapp/config/packages/framework.yaml b/webapp/config/packages/framework.yaml index deb14179c1b..ed7df0ab36e 100644 --- a/webapp/config/packages/framework.yaml +++ b/webapp/config/packages/framework.yaml @@ -6,6 +6,9 @@ framework: http_method_override: true annotations: true handle_all_throwables: true + serializer: + enabled: true + name_converter: serializer.name_converter.camel_case_to_snake_case # Enables session support. Note that the session will ONLY be started if you read or write from it. # Remove or comment this section to explicitly disable session support. diff --git a/webapp/config/packages/nelmio_api_doc.yaml b/webapp/config/packages/nelmio_api_doc.yaml index 337c7d731bf..2453372b42a 100644 --- a/webapp/config/packages/nelmio_api_doc.yaml +++ b/webapp/config/packages/nelmio_api_doc.yaml @@ -124,107 +124,5 @@ nelmio_api_doc: text/html: schema: type: string - schemas: - AddUser: - required: - - username - - name - - password - - roles - type: object - properties: - username: - type: string - name: - type: string - email: - type: string - format: email - ip: - type: string - password: - type: string - format: password - enabled: - type: boolean - nullable: true - team_id: - type: string - roles: - type: array - items: - type: string - ClarificationPost: - type: object - required: [text] - properties: - text: - type: string - description: The body of the clarification to send - problem_id: - type: string - description: The problem the clarification is for - nullable: true - reply_to_id: - type: string - description: The ID of the clarification this clarification is a reply to - nullable: true - from_team_id: - type: string - description: The team the clarification came from. Only used when adding a clarification as admin - nullable: true - to_team_id: - type: string - description: The team the clarification must be sent to. Only used when adding a clarification as admin - nullable: true - time: - type: string - format: date-time - description: The time to use for the clarification. Only used when adding a clarification as admin - id: - type: string - description: The ID to use for the clarification. Only used when adding a clarification as admin and only allowed with PUT - ContestProblemPut: - type: object - required: [label] - properties: - label: - type: string - description: The label of the problem to add to the contest - color: - type: string - description: Human readable color of the problem to add. Will be overwritten by `rgb` if supplied - rgb: - type: string - description: Hexadecimal RGB value of the color of the problem to add. Will be used if both `color` and `rgb` are supplied - points: - type: integer - description: The number of points for the problem to add. Defaults to 1 - lazy_eval_results: - type: boolean - description: Whether to use lazy evaluation for this problem. Defaults to the global setting - TeamCategoryPost: - type: object - required: [name] - properties: - hidden: - type: boolean - description: Show this group on the scoreboard? - nullable: true - icpc_id: - type: string - description: The ID in the ICPC CMS for this group - nullable: true - name: - type: string - description: How to name this group on the scoreboard - sortorder: - type: integer - minimum: 0 - description: Bundle groups with the same sortorder, create different scoreboards per sortorder - color: - type: string - nullable: true - description: Color to use for teams in this group on the scoreboard areas: path_patterns: [ ^/api/v4 ] diff --git a/webapp/src/Controller/API/AbstractRestController.php b/webapp/src/Controller/API/AbstractRestController.php index 99c5a8b6df1..4bab01e592b 100644 --- a/webapp/src/Controller/API/AbstractRestController.php +++ b/webapp/src/Controller/API/AbstractRestController.php @@ -137,11 +137,15 @@ protected function renderCreateData( $params = [ 'id' => $id, ]; + $postfix = ''; if ($routeType !== 'user') { $params['cid'] = $request->attributes->get('cid'); + if ($params['cid'] === null) { + $postfix = '_1'; + } } $headers = [ - 'Location' => $this->generateUrl("v4_app_api_{$routeType}_single", $params, UrlGeneratorInterface::ABSOLUTE_URL), + 'Location' => $this->generateUrl("v4_app_api_{$routeType}_single$postfix", $params, UrlGeneratorInterface::ABSOLUTE_URL), ]; return $this->renderData($request, $data, Response::HTTP_CREATED, $headers); diff --git a/webapp/src/Controller/API/ClarificationController.php b/webapp/src/Controller/API/ClarificationController.php index 17fe05722f5..a9151cb2239 100644 --- a/webapp/src/Controller/API/ClarificationController.php +++ b/webapp/src/Controller/API/ClarificationController.php @@ -2,6 +2,7 @@ namespace App\Controller\API; +use App\DataTransferObject\ClarificationPost; use App\Entity\Clarification; use App\Entity\Contest; use App\Entity\ContestProblem; @@ -17,6 +18,7 @@ use Symfony\Component\ExpressionLanguage\Expression; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\Security\Http\Attribute\IsGranted; @@ -92,11 +94,8 @@ public function singleAction(Request $request, string $id): Response content: [ new OA\MediaType( mediaType: 'multipart/form-data', - schema: new OA\Schema(ref: '#/components/schemas/ClarificationPost') - ), - new OA\MediaType( - mediaType: 'application/json', - schema: new OA\Schema(ref: '#/components/schemas/ClarificationPost')) + schema: new OA\Schema(ref: new Model(type: ClarificationPost::class)) + ) ] )] #[OA\Response( @@ -104,24 +103,21 @@ public function singleAction(Request $request, string $id): Response description: 'When creating a clarification was successful', content: new Model(type: Clarification::class) )] - public function addAction(Request $request, ?string $id): Response - { - $required = ['text']; - foreach ($required as $argument) { - if (!$request->request->has($argument)) { - throw new BadRequestHttpException(sprintf("Argument '%s' is mandatory.", $argument)); - } - } - + public function addAction( + #[MapRequestPayload(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)] + ClarificationPost $clarificationPost, + Request $request, + ?string $id + ): Response { $contestId = $this->getContestId($request); $contest = $this->em->getRepository(Contest::class)->find($contestId); $clarification = new Clarification(); $clarification ->setContest($contest) - ->setBody($request->request->get('text')); + ->setBody($clarificationPost->text); - if ($problemId = $request->request->get('problem_id')) { + if ($problemId = $clarificationPost->problemId) { // Load the problem /** @var ContestProblem|null $problem */ $problem = $this->em->createQueryBuilder() @@ -146,7 +142,7 @@ public function addAction(Request $request, ?string $id): Response $clarification->setProblem($problem->getProblem()); } - if ($replyToId = $request->request->get('reply_to_id')) { + if ($replyToId = $clarificationPost->replyToId) { // Load the clarification. /** @var Clarification|null $replyTo */ $replyTo = $this->em->createQueryBuilder() @@ -169,7 +165,7 @@ public function addAction(Request $request, ?string $id): Response // By default, use the team of the user $fromTeam = $this->isGranted('ROLE_API_WRITER') ? null : $this->dj->getUser()->getTeam(); - if ($fromTeamId = $request->request->get('from_team_id')) { + if ($fromTeamId = $clarificationPost->fromTeamId) { $idField = $this->eventLogService->externalIdFieldForEntity(Team::class) ?? 'teamid'; $method = sprintf('get%s', ucfirst($idField)); @@ -189,7 +185,7 @@ public function addAction(Request $request, ?string $id): Response // By default, send to jury. $toTeam = null; - if ($toTeamId = $request->request->get('to_team_id')) { + if ($toTeamId = $clarificationPost->toTeamId) { $idField = $this->eventLogService->externalIdFieldForEntity(Team::class) ?? 'teamid'; // If the user is an admin or API writer, allow it to specify the team. @@ -207,7 +203,7 @@ public function addAction(Request $request, ?string $id): Response } $time = Utils::now(); - if ($timeString = $request->request->get('time')) { + if ($timeString = $clarificationPost->time) { if ($this->isGranted('ROLE_API_WRITER')) { try { $time = Utils::toEpochFloat($timeString); @@ -221,7 +217,7 @@ public function addAction(Request $request, ?string $id): Response $clarification->setSubmittime($time); - if ($clarificationId = $request->request->get('id')) { + if ($clarificationId = $clarificationPost->id) { if ($request->isMethod('POST')) { throw new BadRequestHttpException('Passing an ID is not supported for POST.'); } elseif ($id !== $clarificationId) { diff --git a/webapp/src/Controller/API/ContestController.php b/webapp/src/Controller/API/ContestController.php index 8cfc621fc90..f2de4dfcbe2 100644 --- a/webapp/src/Controller/API/ContestController.php +++ b/webapp/src/Controller/API/ContestController.php @@ -4,6 +4,7 @@ use App\DataTransferObject\ContestState; use App\DataTransferObject\ContestStatus; +use App\DataTransferObject\PatchContest; use App\Entity\Contest; use App\Entity\ContestProblem; use App\Entity\Event; @@ -28,6 +29,7 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\ExpressionLanguage\Expression; use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; +use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\JsonResponse; @@ -268,33 +270,7 @@ public function setBannerAction(Request $request, string $cid, ValidatorInterfac required: true, content: new OA\MediaType( mediaType: 'application/x-www-form-urlencoded', - schema: new OA\Schema( - required: ['id'], - properties: [ - new OA\Property( - property: 'id', - description: 'The ID of the contest to change the start time for', - type: 'string' - ), - new OA\Property( - property: 'start_time', - description: 'The new start time of the contest', - type: 'string', - format: 'date-time' - ), - new OA\Property( - property: 'scoreboard_thaw_time', - description: 'The new unfreeze (thaw) time of the contest', - type: 'string', - format: 'date-time' - ), - new OA\Property( - property: 'force', - description: '"Force overwriting the start_time even when in next 30s or the scoreboard_thaw_time when already set or too much in the past"', - type: 'boolean' - ), - ] - ) + schema: new OA\Schema(ref: new Model(type: PatchContest::class)) ) )] #[OA\Response( @@ -307,6 +283,8 @@ public function setBannerAction(Request $request, string $cid, ValidatorInterfac content: new OA\JsonContent(ref: new Model(type: Contest::class)) )] public function changeStartTimeAction( + #[MapRequestPayload(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)] + PatchContest $patchContest, Request $request, #[OA\PathParameter(description: 'The ID of the contest to change the start time for')] string $cid @@ -315,9 +293,7 @@ public function changeStartTimeAction( $contest = $this->getContestWithId($request, $cid); $now = (int)Utils::now(); $changed = false; - if (!$request->request->has('id')) { - throw new BadRequestHttpException('Missing "id" in request.'); - } + // We still need these checks explicit check since they can be null. if (!$request->request->has('start_time') && !$request->request->has('scoreboard_thaw_time')) { throw new BadRequestHttpException('Missing "start_time" or "scoreboard_thaw_time" in request.'); } @@ -331,24 +307,24 @@ public function changeStartTimeAction( if ($request->request->has('start_time')) { // By default, it is not allowed to change the start time in the last 30 seconds before contest start. // We allow the "force" parameter to override this. - if (!$request->request->getBoolean('force') && + if (!$patchContest->force && $contest->getStarttime() != null && $contest->getStarttime() < $now + 30) { throw new AccessDeniedHttpException('Current contest already started or about to start.'); } - if ($request->request->get('start_time') === null) { + if ($patchContest->startTime === null) { $contest->setStarttimeEnabled(false); $this->em->flush(); $changed = true; } else { - $date = date_create($request->request->get('start_time')); + $date = date_create($patchContest->startTime); if ($date === false) { throw new BadRequestHttpException('Invalid "start_time" in request.'); } $new_start_time = $date->getTimestamp(); - if (!$request->request->getBoolean('force') && $new_start_time < $now + 30) { + if (!$patchContest->force && $new_start_time < $now + 30) { throw new AccessDeniedHttpException('New start_time not far enough in the future.'); } $newStartTimeString = date('Y-m-d H:i:s e', $new_start_time); @@ -360,17 +336,17 @@ public function changeStartTimeAction( } } if ($request->request->has('scoreboard_thaw_time')) { - if (!$request->request->getBoolean('force') && $contest->getUnfreezetime() !== null) { + if (!$patchContest->force && $contest->getUnfreezetime() !== null) { throw new AccessDeniedHttpException('Current contest already has an unfreeze time set.'); } - $date = date_create($request->request->get('scoreboard_thaw_time') ?? 'not a valid date'); + $date = date_create($patchContest->scoreboardThawTime ?? 'not a valid date'); if ($date === false) { throw new BadRequestHttpException('Invalid "scoreboard_thaw_time" in request.'); } $new_unfreeze_time = $date->getTimestamp(); - if (!$request->request->getBoolean('force') && $new_unfreeze_time < $now - 30) { + if (!$patchContest->force && $new_unfreeze_time < $now - 30) { throw new AccessDeniedHttpException('New scoreboard_thaw_time too far in the past.'); } diff --git a/webapp/src/Controller/API/GroupController.php b/webapp/src/Controller/API/GroupController.php index af3beeea342..93cbd76f04b 100644 --- a/webapp/src/Controller/API/GroupController.php +++ b/webapp/src/Controller/API/GroupController.php @@ -2,6 +2,7 @@ namespace App\Controller\API; +use App\DataTransferObject\TeamCategoryPost; use App\Entity\TeamCategory; use App\Service\ImportExportService; use Doctrine\ORM\NonUniqueResultException; @@ -9,6 +10,7 @@ use FOS\RestBundle\Controller\Annotations as Rest; use Nelmio\ApiDocBundle\Annotation\Model; use OpenApi\Attributes as OA; +use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -76,11 +78,7 @@ public function singleAction(Request $request, string $id): Response content: [ new OA\MediaType( mediaType: 'multipart/form-data', - schema: new OA\Schema(ref: '#/components/schemas/TeamCategoryPost') - ), - new OA\MediaType( - mediaType: 'application/json', - schema: new OA\Schema(ref: '#/components/schemas/TeamCategoryPost') + schema: new OA\Schema(ref: new Model(type: TeamCategoryPost::class)) ), ] )] @@ -89,14 +87,23 @@ public function singleAction(Request $request, string $id): Response description: 'Returns the added group', content: new Model(type: TeamCategory::class) )] - public function addAction(Request $request, ImportExportService $importExport): Response - { + public function addAction( + #[MapRequestPayload(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)] + TeamCategoryPost $teamCategoryPost, + Request $request, + ImportExportService $importExport + ): Response { $saved = []; - $postedData = $request->request->all(); - if (array_key_exists('id', $postedData)) { - throw new BadRequestHttpException("Cannot add group with ID"); - } - $importExport->importGroupsJson([$postedData], $message, $saved); + $importExport->importGroupsJson([ + [ + 'name' => $teamCategoryPost->name, + 'hidden' => $teamCategoryPost->hidden, + 'icpc_id' => $teamCategoryPost->icpcId, + 'sortorder' => $teamCategoryPost->sortorder, + 'color' => $teamCategoryPost->color, + 'allow_self_registration' => $teamCategoryPost->allowSelfRegistration, + ], + ], $message, $saved); if (!empty($message)) { throw new BadRequestHttpException("Error while adding group: $message"); } diff --git a/webapp/src/Controller/API/OrganizationController.php b/webapp/src/Controller/API/OrganizationController.php index d3e63f4dcba..757afb72491 100644 --- a/webapp/src/Controller/API/OrganizationController.php +++ b/webapp/src/Controller/API/OrganizationController.php @@ -2,6 +2,7 @@ namespace App\Controller\API; +use App\DataTransferObject\AddOrganization; use App\Entity\TeamAffiliation; use App\Service\AssetUpdateService; use App\Service\ConfigurationService; @@ -14,6 +15,7 @@ use FOS\RestBundle\Controller\Annotations as Rest; use Nelmio\ApiDocBundle\Annotation\Model; use OpenApi\Attributes as OA; +use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\JsonResponse; @@ -232,11 +234,7 @@ public function setLogoAction(Request $request, string $id, ValidatorInterface $ content: [ new OA\MediaType( mediaType: 'multipart/form-data', - schema: new OA\Schema(ref: '#/components/schemas/TeamAffiliation') - ), - new OA\MediaType( - mediaType: 'application/json', - schema: new OA\Schema(ref: '#/components/schemas/TeamAffiliation') + schema: new OA\Schema(ref: new Model(type: AddOrganization::class)) ), ] )] @@ -245,10 +243,22 @@ public function setLogoAction(Request $request, string $id, ValidatorInterface $ description: 'Returns the added organization', content: new Model(type: TeamAffiliation::class) )] - public function addAction(Request $request, ImportExportService $importExport): Response - { + public function addAction( + #[MapRequestPayload(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)] + AddOrganization $addOrganization, + Request $request, + ImportExportService $importExport + ): Response { $saved = []; - $importExport->importOrganizationsJson([$request->request->all()], $message, $saved); + $importExport->importOrganizationsJson([ + [ + 'id' => $addOrganization->id, + 'shortname' => $addOrganization->shortname, + 'name' => $addOrganization->formalName ?? $addOrganization->name, + 'country' => $addOrganization->country, + 'icpc_id' => $addOrganization->icpcId, + ], + ], $message, $saved); if (!empty($message)) { throw new BadRequestHttpException("Error while adding organization: $message"); } diff --git a/webapp/src/Controller/API/ProblemController.php b/webapp/src/Controller/API/ProblemController.php index 4791d85ab9c..5e266ca4d39 100644 --- a/webapp/src/Controller/API/ProblemController.php +++ b/webapp/src/Controller/API/ProblemController.php @@ -3,6 +3,7 @@ namespace App\Controller\API; use App\DataTransferObject\ContestProblemArray; +use App\DataTransferObject\ContestProblemPut; use App\DataTransferObject\ContestProblemWrapper; use App\Entity\Contest; use App\Entity\ContestProblem; @@ -21,6 +22,7 @@ use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -282,28 +284,19 @@ public function unlinkProblemAction(Request $request, string $id): Response */ #[IsGranted('ROLE_ADMIN')] #[Rest\Put('/{id}')] - #[OA\RequestBody( - required: true, - content: new OA\MediaType( - mediaType: 'application/json', - schema: new OA\Schema(ref: '#/components/schemas/ContestProblemPut') - ) - )] #[OA\Response( response: 200, description: 'Returns the linked problem for this contest', content: new OA\JsonContent(ref: new Model(type: ContestProblem::class)) )] #[OA\Parameter(ref: '#/components/parameters/id')] - public function linkProblemAction(Request $request, string $id): Response + public function linkProblemAction( + #[MapRequestPayload(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)] + ContestProblemPut $contestProblemPut, + Request $request, + string $id + ): Response { - $required = ['label']; - foreach ($required as $argument) { - if (!$request->request->has($argument)) { - throw new BadRequestHttpException(sprintf("Argument '%s' is mandatory.", $argument)); - } - } - $problem = $this->em->createQueryBuilder() ->from(Problem::class, 'p') ->select('p') @@ -341,16 +334,16 @@ public function linkProblemAction(Request $request, string $id): Response $contest = $this->em->getRepository(Contest::class)->find($this->getContestId($request)); $lazyEvalResults = null; - if ($request->request->has('lazy_eval_results')) { - $lazyEvalResults = (int)$request->request->getBoolean('lazy_eval_results'); + if ($contestProblemPut->lazyEvalResults !== null) { + $lazyEvalResults = (int)$contestProblemPut->lazyEvalResults; } $contestProblem = (new ContestProblem()) ->setContest($contest) ->setProblem($problem) - ->setShortname($request->request->get('label')) - ->setColor($request->request->get('rgb') ?? $request->request->get('color')) - ->setPoints($request->request->getInt('points', 1)) + ->setShortname($contestProblemPut->label) + ->setColor($contestProblemPut->rgb ?? $contestProblemPut->color) + ->setPoints($contestProblemPut->points) ->setLazyEvalResults($lazyEvalResults); $this->em->persist($contestProblem); diff --git a/webapp/src/Controller/API/SubmissionController.php b/webapp/src/Controller/API/SubmissionController.php index aae06c76894..e8ba3c8e283 100644 --- a/webapp/src/Controller/API/SubmissionController.php +++ b/webapp/src/Controller/API/SubmissionController.php @@ -2,6 +2,7 @@ namespace App\Controller\API; +use App\DataTransferObject\AddSubmission; use App\DataTransferObject\SourceCode; use App\Entity\Contest; use App\Entity\ContestProblem; @@ -24,6 +25,7 @@ use Nelmio\ApiDocBundle\Annotation\Model; use OpenApi\Attributes as OA; use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\Request; @@ -110,89 +112,7 @@ public function singleAction(Request $request, string $id): Response content: [ new OA\MediaType( mediaType: 'multipart/form-data', - schema: new OA\Schema( - required: ['problem', 'language', 'code'], - properties: [ - new OA\Property( - property: 'problem', - description: 'The problem to submit a solution for', - type: 'string' - ), - new OA\Property( - property: 'language', - description: 'The language to submit a solution in', - type: 'string' - ), - new OA\Property( - property: 'code', - description: 'The file(s) to submit', - type: 'array', - items: new OA\Items(type: 'string', format: 'binary') - ), - new OA\Property( - property: 'entry_point', - description: 'The entry point for the submission. Required for languages requiring an entry point', - type: 'string' - ), - ] - ) - ), - new OA\MediaType( - mediaType: 'application/json', - schema: new OA\Schema( - required: ['problem_id', 'language_id', 'files'], - properties: [ - new OA\Property( - property: 'problem_id', - description: 'The problem ID to submit a solution for', - type: 'string' - ), - new OA\Property( - property: 'language_id', - description: 'The language to submit a solution in', - type: 'string' - ), - new OA\Property( - property: 'team_id', - description: 'The team to submit a solution for. Only used when adding a submission as admin', - type: 'string' - ), - new OA\Property( - property: 'time', - description: 'The time to use for the submission. Only used when adding a submission as admin', - type: 'string', - format: 'date-time' - ), - new OA\Property( - property: 'id', - description: 'The ID to use for the submission. Only used when adding a submission as admin and only allowed with PUT', - type: 'string' - ), - new OA\Property( - property: 'files', - description: 'The base64 encoded ZIP file to submit', - type: 'array', - items: new OA\Items( - required: ['data'], - properties: [ - new OA\Property( - property: 'data', - description: 'The base64 encoded ZIP archive', - type: 'string' - ), - ], - type: 'object' - ), - maxItems: 1, - minItems: 1 - ), - new OA\Property( - property: 'entry_point', - description: 'The entry point for the submission. Required for languages requiring an entry point', - type: 'string' - ), - ] - ) + schema: new OA\Schema(ref: new Model(type: AddSubmission::class)) ), ] )] @@ -201,33 +121,19 @@ public function singleAction(Request $request, string $id): Response description: 'When submitting was successful', content: new OA\JsonContent(ref: new Model(type: Submission::class)) )] - public function addSubmissionAction(Request $request, ?string $id): Response - { - $required = [ - 'problem' => ['problem', 'problem_id'], - 'language' => ['language', 'language_id'], - ]; - $data = []; - - foreach ($required as $field => $requiredList) { - $hasAny = false; - foreach ($requiredList as $argument) { - if ($request->request->has($argument)) { - $data[$field] = $request->request->get($argument); - $hasAny = true; - } - } - if (!$hasAny) { - $requiredListQuoted = array_map(fn($item) => "'$item'", $requiredList); - throw new BadRequestHttpException( - sprintf("One of the arguments %s is mandatory.", implode(', ', $requiredListQuoted))); - } - } + public function addSubmissionAction( + #[MapRequestPayload(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)] + AddSubmission $addSubmission, + Request $request, + ?string $id + ): Response { + $problemId = $addSubmission->problem ?? $addSubmission->problemId; + $languageId = $addSubmission->language ?? $addSubmission->languageId; // By default, use the user and team of the user. $user = $this->dj->getUser(); $team = $user->getTeam(); - if ($teamId = $request->request->get('team_id')) { + if ($teamId = $addSubmission->teamId) { $idField = $this->eventLogService->externalIdFieldForEntity(Team::class) ?? 'teamid'; $method = sprintf('get%s', ucfirst($idField)); @@ -247,7 +153,7 @@ public function addSubmissionAction(Request $request, ?string $id): Response throw new BadRequestHttpException('User does not belong to a team.'); } - if ($userId = $request->request->get('user_id')) { + if ($userId = $addSubmission->userId) { // If the current user is an admin or API writer, allow it to specify the user. if ($this->isGranted('ROLE_API_WRITER')) { // Load the user. @@ -282,14 +188,14 @@ public function addSubmissionAction(Request $request, ?string $id): Response $this->eventLogService->externalIdFieldForEntity(Problem::class) ?? 'probid')) ->andWhere('cp.contest = :contest') ->andWhere('cp.allowSubmit = 1') - ->setParameter('problem', $data['problem']) + ->setParameter('problem', $problemId) ->setParameter('contest', $this->getContestId($request)) ->getQuery() ->getOneOrNullResult(); if ($problem === null) { throw new BadRequestHttpException( - sprintf("Problem '%s' not found or not submittable.", $data['problem'])); + sprintf("Problem '%s' not found or not submittable.", $problemId)); } // Load the language. @@ -300,27 +206,27 @@ public function addSubmissionAction(Request $request, ?string $id): Response ->andWhere(sprintf('lang.%s = :language', $this->eventLogService->externalIdFieldForEntity(Language::class) ?? 'langid')) ->andWhere('lang.allowSubmit = 1') - ->setParameter('language', $data['language']) + ->setParameter('language', $languageId) ->getQuery() ->getOneOrNullResult(); if ($language === null) { throw new BadRequestHttpException( - sprintf("Language '%s' not found or not submittable.", $data['language'])); + sprintf("Language '%s' not found or not submittable.", $languageId)); } // Determine the entry point. $entryPoint = null; if ($language->getRequireEntryPoint()) { - if (!$request->request->get('entry_point')) { + if (!$addSubmission->entryPoint) { $entryPointDescription = $language->getEntryPointDescription() ?: 'Entry point'; throw new BadRequestHttpException(sprintf('%s required, but not specified.', $entryPointDescription)); } - $entryPoint = $request->request->get('entry_point'); + $entryPoint = $addSubmission->entryPoint; } $time = null; - if ($timeString = $request->request->get('time')) { + if ($timeString = $addSubmission->time) { if ($this->isGranted('ROLE_API_WRITER')) { try { $time = Utils::toEpochFloat($timeString); @@ -332,7 +238,7 @@ public function addSubmissionAction(Request $request, ?string $id): Response } } - if ($submissionId = $request->request->get('id')) { + if ($submissionId = $addSubmission->id) { if ($request->isMethod('POST')) { throw new BadRequestHttpException('Passing an ID is not supported for POST.'); } elseif ($id !== $submissionId) { @@ -362,18 +268,17 @@ public function addSubmissionAction(Request $request, ?string $id): Response $tempFiles = []; - if ($request->request->has('files')) { + if ($addSubmission->files) { // CCS spec format, files are a ZIP, get them and transform them into a file object. - $filesList = $request->request->all('files'); - if (count($filesList) !== 1 || !isset($filesList[0]['data'])) { + if (count($addSubmission->files) !== 1 || !isset($addSubmission->files[0]->data)) { throw new BadRequestHttpException("The 'files' attribute must be an array with a single item, containing an object with a base64 encoded data field."); } - if (isset($filesList[0]['mime']) && $filesList[0]['mime'] !== 'application/zip') { + if ($addSubmission->files[0]->mime && $addSubmission->files[0]->mime !== 'application/zip') { throw new BadRequestHttpException("The 'files[0].mime' attribute must be application/zip if provided."); } - $data = $filesList[0]['data']; + $data = $addSubmission->files[0]->data; $decodedData = base64_decode($data, true); if ($decodedData === false) { throw new BadRequestHttpException("The 'files[0].data' attribute is not base64 encoded."); diff --git a/webapp/src/Controller/API/TeamController.php b/webapp/src/Controller/API/TeamController.php index 3e9563662df..63374228e6d 100644 --- a/webapp/src/Controller/API/TeamController.php +++ b/webapp/src/Controller/API/TeamController.php @@ -2,6 +2,7 @@ namespace App\Controller\API; +use App\DataTransferObject\AddTeam; use App\Entity\Contest; use App\Entity\Team; use App\Service\AssetUpdateService; @@ -15,6 +16,7 @@ use FOS\RestBundle\Controller\Annotations as Rest; use Nelmio\ApiDocBundle\Annotation\Model; use OpenApi\Attributes as OA; +use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\JsonResponse; @@ -244,10 +246,7 @@ public function setPhotoAction(Request $request, string $id, ValidatorInterface content: [ new OA\MediaType( mediaType: 'multipart/form-data', - schema: new OA\Schema(ref: '#/components/schemas/Team')), - new OA\MediaType( - mediaType: 'application/json', - schema: new OA\Schema(ref: '#/components/schemas/Team')), + schema: new OA\Schema(ref: new Model(type: AddTeam::class))), ] )] #[OA\Response( @@ -255,10 +254,29 @@ public function setPhotoAction(Request $request, string $id, ValidatorInterface description: 'Returns the added team', content: new Model(type: Team::class) )] - public function addAction(Request $request, ImportExportService $importExport): Response - { + public function addAction( + #[MapRequestPayload(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)] + AddTeam $addTeam, + Request $request, + ImportExportService $importExport + ): Response { $saved = []; - $importExport->importTeamsJson([$request->request->all()], $message, $saved); + $importExport->importTeamsJson([ + [ + 'id' => $addTeam->id, + 'icpc_id' => $addTeam->icpcId, + 'label' => $addTeam->label, + 'group_ids' => $addTeam->groupIds, + 'name' => $addTeam->name, + 'display_name' => $addTeam->displayName, + 'public_description' => $addTeam->publicDescription, + 'members' => $addTeam->members, + 'location' => [ + 'description' => $addTeam->location?->description, + ], + 'organization_id' => $addTeam->organizationId, + ], + ], $message, $saved); if (!empty($message)) { throw new BadRequestHttpException("Error while adding team: $message"); } diff --git a/webapp/src/Controller/API/UserController.php b/webapp/src/Controller/API/UserController.php index c8cff621d57..2e3dc496a04 100644 --- a/webapp/src/Controller/API/UserController.php +++ b/webapp/src/Controller/API/UserController.php @@ -2,6 +2,7 @@ namespace App\Controller\API; +use App\DataTransferObject\AddUser; use App\Entity\Role; use App\Entity\Team; use App\Entity\User; @@ -16,6 +17,7 @@ use Nelmio\ApiDocBundle\Annotation\Model; use OpenApi\Attributes as OA; use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\Request; @@ -291,11 +293,7 @@ public function singleAction(Request $request, string $id): Response content: [ new OA\MediaType( mediaType: 'multipart/form-data', - schema: new OA\Schema(ref: '#/components/schemas/AddUser') - ), - new OA\MediaType( - mediaType: 'application/json', - schema: new OA\Schema(ref: '#/components/schemas/AddUser') + schema: new OA\Schema(ref: new Model(type: AddUser::class)) ) ] )] @@ -304,52 +302,42 @@ public function singleAction(Request $request, string $id): Response description: 'Returns the added user', content: new Model(type: User::class) )] - public function addAction(Request $request): Response - { - $required = [ - 'username', - 'name', - 'roles', - ]; - - foreach ($required as $argument) { - if (!$request->request->has($argument)) { - throw new BadRequestHttpException( - sprintf("Argument '%s' is mandatory", $argument)); - } - } - - if ($this->em->getRepository(User::class)->findOneBy(['username' => $request->request->get('username')])) { - throw new BadRequestHttpException(sprintf("User %s already exists", $request->request->get('username'))); + public function addAction( + #[MapRequestPayload(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)] + AddUser $addUser, + Request $request + ): Response { + if ($this->em->getRepository(User::class)->findOneBy(['username' => $addUser->username])) { + throw new BadRequestHttpException(sprintf("User %s already exists", $addUser->username)); } $user = new User(); $user - ->setUsername($request->request->get('username')) - ->setName($request->request->get('name')) - ->setEmail($request->request->get('email')) - ->setIpAddress($request->request->get('ip')) - ->setPlainPassword($request->request->get('password')) - ->setEnabled($request->request->getBoolean('enabled', true)); + ->setUsername($addUser->username) + ->setName($addUser->name) + ->setEmail($addUser->email) + ->setIpAddress($addUser->ip) + ->setPlainPassword($addUser->password) + ->setEnabled($addUser->enabled ?? true); - if ($request->request->get('team_id')) { + if ($addUser->teamId) { /** @var Team|null $team */ $team = $this->em->createQueryBuilder() ->from(Team::class, 't') ->select('t') ->andWhere(sprintf('t.%s = :team', $this->eventLogService->externalIdFieldForEntity(Team::class) ?? 'teamid')) - ->setParameter('team', $request->request->get('team_id')) + ->setParameter('team', $addUser->teamId) ->getQuery() ->getOneOrNullResult(); if ($team === null) { - throw new BadRequestHttpException(sprintf("Team %s not found", $request->request->get('team_id'))); + throw new BadRequestHttpException(sprintf("Team %s not found", $addUser->teamId)); } $user->setTeam($team); } - $roles = $request->request->all('roles'); + $roles = $addUser->roles; // For the file import we change a CDS user to the roles needed for ICPC CDS. if ($user->getUsername() === 'cds') { $roles = ['cds']; diff --git a/webapp/src/DataTransferObject/AddOrganization.php b/webapp/src/DataTransferObject/AddOrganization.php new file mode 100644 index 00000000000..76cb3b65afd --- /dev/null +++ b/webapp/src/DataTransferObject/AddOrganization.php @@ -0,0 +1,23 @@ +problem && !$this->problemId) { + $context + ->buildViolation("One of the arguments 'problem', 'problem_id' is required") + ->atPath('problem') + ->addViolation(); + $context + ->buildViolation("One of the arguments 'problem', 'problem_id' is required") + ->atPath('problem_id') + ->addViolation(); + } + if (!$this->language && !$this->languageId) { + $context + ->buildViolation("One of the arguments 'language', 'language_id' is required") + ->atPath('language') + ->addViolation(); + $context + ->buildViolation("One of the arguments 'language', 'language_id' is required") + ->atPath('language_id') + ->addViolation(); + } + } +} diff --git a/webapp/src/DataTransferObject/AddSubmissionFile.php b/webapp/src/DataTransferObject/AddSubmissionFile.php new file mode 100644 index 00000000000..4cea289b59f --- /dev/null +++ b/webapp/src/DataTransferObject/AddSubmissionFile.php @@ -0,0 +1,15 @@ + $roles + */ + public function __construct( + public readonly string $username, + public readonly string $name, + #[OA\Property(format: 'email', nullable: true)] + public readonly ?string $email, + #[OA\Property(nullable: true)] + public readonly ?string $ip, + #[OA\Property(format: 'password', nullable: true)] + public readonly ?string $password, + #[OA\Property(nullable: true)] + public readonly ?bool $enabled, + #[OA\Property(nullable: true)] + public readonly ?string $teamId, + #[Serializer\Type('array')] + public readonly array $roles, + ) {} +} diff --git a/webapp/src/DataTransferObject/ClarificationPost.php b/webapp/src/DataTransferObject/ClarificationPost.php new file mode 100644 index 00000000000..c034017e7d9 --- /dev/null +++ b/webapp/src/DataTransferObject/ClarificationPost.php @@ -0,0 +1,26 @@ +getStatusCode(); } + dump($exception); + + if ($exception->getPrevious() && $exception->getPrevious()->getClass() === ValidationFailedException::class) { + $exception = $exception->getPrevious(); + } + $showMessage = $this->messagesMap->resolveFromClassName($exception->getClass()); if ($showMessage || $this->debug) { diff --git a/webapp/tests/Unit/Controller/API/ClarificationControllerTest.php b/webapp/tests/Unit/Controller/API/ClarificationControllerTest.php index b9fd65a465a..4c3880f667d 100644 --- a/webapp/tests/Unit/Controller/API/ClarificationControllerTest.php +++ b/webapp/tests/Unit/Controller/API/ClarificationControllerTest.php @@ -8,6 +8,7 @@ use App\Entity\Problem; use Doctrine\ORM\EntityManagerInterface; use Generator; +use Symfony\Component\HttpFoundation\Response; class ClarificationControllerTest extends BaseTestCase { @@ -132,12 +133,17 @@ public function testAddInvalidData(string $user, array $dataToSend, string $expe $url .= '/' . $dataToSend['id']; } $data = $this->verifyApiJsonResponse($method, $url, 400, $user, $dataToSend); - static::assertEquals($expectedMessage, $data['message']); + if (str_starts_with($expectedMessage, '/')) { + static::assertMatchesRegularExpression($expectedMessage, $data['message']); + } else { + static::assertEquals($expectedMessage, $data['message']); + } } public function provideAddInvalidData(): Generator { - yield ['demo', [], "Argument 'text' is mandatory."]; + yield ['demo', [], ""]; + yield ['demo', ['invalidfield' => 'value'], "/text:\n.*This value should be of type unknown./"]; yield ['demo', ['text' => 'This is a clarification', 'from_team_id' => '1'], "Can not create a clarification from a different team."]; yield ['demo', ['text' => 'This is a clarification', 'to_team_id' => '2'], "Can not create a clarification that is sent to a team."]; yield ['demo', ['text' => 'This is a clarification', 'problem_id' => '4'], "Problem '4' not found."]; diff --git a/webapp/tests/Unit/Controller/API/ContestControllerAdminTest.php b/webapp/tests/Unit/Controller/API/ContestControllerAdminTest.php index 64d3eafadc4..9b7f6ececb4 100644 --- a/webapp/tests/Unit/Controller/API/ContestControllerAdminTest.php +++ b/webapp/tests/Unit/Controller/API/ContestControllerAdminTest.php @@ -214,7 +214,7 @@ public function provideChangeTimes(): Generator // Note that if the first item contains "id", we replace it with the correct ID in the test // General tests - yield [[], 400, 'Missing \"id\" in request.']; + yield [['dummy' => 'dummy'], 400, "This value should be of type unknown."]; yield [['id' => 1], 400, 'Missing \"start_time\" or \"scoreboard_thaw_time\" in request.']; yield [['id' => 1, 'start_time' => null, 'scoreboard_thaw_time' => date('Y-m-d\TH:i:s', strtotime('+15 seconds'))], 400, 'Setting both \"start_time\" and \"scoreboard_thaw_time\" at the same time is not allowed.']; diff --git a/webapp/tests/Unit/Controller/API/GroupControllerTest.php b/webapp/tests/Unit/Controller/API/GroupControllerTest.php index 9f66f8b1a77..6b9053fd375 100644 --- a/webapp/tests/Unit/Controller/API/GroupControllerTest.php +++ b/webapp/tests/Unit/Controller/API/GroupControllerTest.php @@ -80,8 +80,9 @@ public function testNewAddedGroupPostWithId(): void $url = $this->helperGetEndpointURL($this->apiEndpoint); $postWithId = $this->newGroupsPostData[0]; $postWithId['id'] = '1'; - // CLICS does not allow POST to set the id value - $this->verifyApiJsonResponse('POST', $url, 400, 'admin', $postWithId); + // CLICS does not allow POST to set the id value. Our API will just ignore the property + $returnedObject = $this->verifyApiJsonResponse('POST', $url, 201, 'admin', $postWithId); + self::assertNotEquals($returnedObject['id'], $postWithId['id']); } public function provideNewAddedGroup(): Generator diff --git a/webapp/tests/Unit/Controller/API/SubmissionControllerTest.php b/webapp/tests/Unit/Controller/API/SubmissionControllerTest.php index ede43f1e7ab..71c6d08087e 100644 --- a/webapp/tests/Unit/Controller/API/SubmissionControllerTest.php +++ b/webapp/tests/Unit/Controller/API/SubmissionControllerTest.php @@ -83,14 +83,19 @@ public function testAddInvalidData(string $user, array $dataToSend, string $expe $url .= '/' . $dataToSend['id']; } $data = $this->verifyApiJsonResponse($method, $url, 400, $user, $dataToSend); - static::assertEquals($expectedMessage, $data['message']); + if (str_starts_with($expectedMessage, '/')) { + static::assertMatchesRegularExpression($expectedMessage, $data['message']); + } else { + static::assertEquals($expectedMessage, $data['message']); + } } public function provideAddInvalidData(): Generator { - yield ['demo', [], "One of the arguments 'problem', 'problem_id' is mandatory."]; - yield ['demo', ['problem' => 1], "One of the arguments 'language', 'language_id' is mandatory."]; - yield ['demo', ['problem_id' => 1], "One of the arguments 'language', 'language_id' is mandatory."]; + yield ['demo', [], ""]; + yield ['demo', ['unknown_key'], "/One of the arguments 'problem', 'problem_id' is required/"]; + yield ['demo', ['problem' => 1], "/One of the arguments 'language', 'language_id' is required/"]; + yield ['demo', ['problem_id' => 1], "/One of the arguments 'language', 'language_id' is required/"]; yield ['demo', ['problem_id' => 4, 'language' => 'cpp'], "Problem '4' not found or not submittable."]; yield ['demo', ['problem_id' => 1, 'language' => 'cpp'], "No files specified."]; yield ['demo', ['problem_id' => 1, 'language_id' => 'cpp'], "No files specified."]; @@ -101,11 +106,12 @@ public function provideAddInvalidData(): Generator yield ['demo', ['problem_id' => 1, 'language_id' => 'cpp', 'user_id' => 1], "Can not submit for a different user."]; yield ['demo', ['problem_id' => 1, 'language_id' => 'cpp', 'id' => '123'], "A team can not assign id."]; yield ['demo', ['problem_id' => 1, 'language_id' => 'cpp', 'time' => '2021-01-01T00:00:00'], "A team can not assign time."]; - yield ['demo', ['problem_id' => 1, 'language_id' => 'cpp', 'files' => []], "The 'files' attribute must be an array with a single item, containing an object with a base64 encoded data field."]; + yield ['demo', ['problem_id' => 1, 'language_id' => 'cpp', 'files' => []], "No files specified."]; + yield ['demo', ['problem_id' => 1, 'language_id' => 'cpp', 'files' => [['invalidkey' => 'somevalue']]], "/files\[0\].data:\n.*This value should be of type unknown./"]; yield [ 'demo', ['problem_id' => 1, 'language_id' => 'cpp', 'files' => 'this is not an array'], - "Unexpected value for parameter \"files\": expecting \"array\", got \"string\"." + "/files:\n.*This value should be of type array/" ]; yield [ 'demo',