diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml new file mode 100644 index 000000000..cf6d2778c --- /dev/null +++ b/.github/workflows/openapi.yml @@ -0,0 +1,94 @@ +# This workflow is provided via the organization template repository +# +# https://github.com/nextcloud/.github +# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization +# +# SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-FileCopyrightText: 2024 Arthur Schiwon +# SPDX-License-Identifier: MIT + +name: OpenAPI + +on: pull_request + +permissions: + contents: read + +concurrency: + group: openapi-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + openapi: + runs-on: ubuntu-latest + + if: ${{ github.repository_owner != 'nextcloud-gmbh' }} + + steps: + - name: Checkout + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + + - name: Get php version + id: php_versions + uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1 + + - name: Set up php + uses: shivammathur/setup-php@c541c155eee45413f5b09a52248675b1a2575231 # v2.31.1 + with: + php-version: ${{ steps.php_versions.outputs.php-available }} + extensions: xml + coverage: none + ini-file: development + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check Typescript OpenApi types + id: check_typescript_openapi + uses: andstor/file-existence-action@076e0072799f4942c8bc574a82233e1e4d13e9d6 # v3.0.0 + with: + files: "src/types/openapi/openapi*.ts" + + - name: Read package.json node and npm engines version + if: steps.check_typescript_openapi.outputs.files_exists == 'true' + uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3 + id: node_versions + # Continue if no package.json + continue-on-error: true + with: + fallbackNode: '^20' + fallbackNpm: '^10' + + - name: Set up node ${{ steps.node_versions.outputs.nodeVersion }} + if: ${{ steps.node_versions.outputs.nodeVersion }} + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 + with: + node-version: ${{ steps.node_versions.outputs.nodeVersion }} + + - name: Set up npm ${{ steps.node_versions.outputs.npmVersion }} + if: ${{ steps.node_versions.outputs.nodeVersion }} + run: npm i -g 'npm@${{ steps.node_versions.outputs.npmVersion }}' + + - name: Install dependencies & build + if: ${{ steps.node_versions.outputs.nodeVersion }} + env: + CYPRESS_INSTALL_BINARY: 0 + PUPPETEER_SKIP_DOWNLOAD: true + run: | + npm ci + + - name: Set up dependencies + run: composer i + + - name: Regenerate OpenAPI + run: composer run openapi + + - name: Check openapi*.json and typescript changes + run: | + bash -c "[[ ! \"`git status --porcelain `\" ]] || (echo 'Please run \"composer run openapi\" and commit the openapi*.json files and (if applicable) src/types/openapi/openapi*.ts, see the section \"Show changes on failure\" for details' && exit 1)" + + - name: Show changes on failure + if: failure() + run: | + git status + git --no-pager diff + exit 1 # make it red to grab attention diff --git a/.prettierignore b/.prettierignore index d04025109..e65599c97 100644 --- a/.prettierignore +++ b/.prettierignore @@ -16,7 +16,11 @@ js/ # Handled by transifex l10n/ +# OpenAPI +openapi.json + # PHP lib/ **/*.php +composer.json composer.lock diff --git a/composer.json b/composer.json index b019428bd..abf2e996b 100644 --- a/composer.json +++ b/composer.json @@ -12,17 +12,15 @@ } }, "scripts": { - "post-install-cmd": [ - "@composer bin psalm install --ansi", - "@composer bin cs-fixer install --ansi" - ], + "post-install-cmd": "@composer bin all install --ansi", "bin": "echo 'bin not installed'", "cs:fix": "php-cs-fixer fix", "cs:check": "php-cs-fixer fix --dry-run --diff", "lint": "find . -name \\*.php -not -path './vendor*/*' -print0 | xargs -0 -n1 php -l", "test:unit": "phpunit -c tests/phpunit.xml", "test:integration": "phpunit -c tests/phpunit.integration.xml", - "psalm": "psalm" + "psalm": "psalm --no-cache", + "openapi": "generate-spec" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8", diff --git a/lib/Capabilities.php b/lib/Capabilities.php index 62d739a5b..5796707ca 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -35,6 +35,7 @@ public function __construct( /** * Provide App Capabilities * @inheritdoc + * @return array{forms: array{version: string, apiVersions: list}} */ public function getCapabilities() { return [ diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 00f08d73d..231131f56 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -44,12 +44,14 @@ use OCA\Forms\Db\SubmissionMapper; use OCA\Forms\Db\UploadedFile; use OCA\Forms\Db\UploadedFileMapper; +use OCA\Forms\ResponseDefinitions; use OCA\Forms\Service\ConfigService; use OCA\Forms\Service\FormsService; use OCA\Forms\Service\SubmissionService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\IMapperException; +use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\Attribute\CORS; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -57,8 +59,8 @@ use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\DataDownloadResponse; use OCP\AppFramework\Http\DataResponse; -use OCP\AppFramework\Http\Response; use OCP\AppFramework\OCS\OCSBadRequestException; +use OCP\AppFramework\OCS\OCSException; use OCP\AppFramework\OCS\OCSForbiddenException; use OCP\AppFramework\OCS\OCSNotFoundException; use OCP\AppFramework\OCSController; @@ -73,6 +75,16 @@ use Psr\Log\LoggerInterface; +/** + * @psalm-import-type FormsForm from ResponseDefinitions + * @psalm-import-type FormsOption from ResponseDefinitions + * @psalm-import-type FormsOrder from ResponseDefinitions + * @psalm-import-type FormsPartialForm from ResponseDefinitions + * @psalm-import-type FormsQuestion from ResponseDefinitions + * @psalm-import-type FormsQuestionType from ResponseDefinitions + * @psalm-import-type FormsSubmissions from ResponseDefinitions + * @psalm-import-type FormsUploadedFile from ResponseDefinitions + */ class ApiController extends OCSController { private ?IUser $currentUser; @@ -104,7 +116,7 @@ public function __construct( /** * Handle CORS options request by calling parent function */ - #[ApiRoute(verb: 'OPTIONS', url: Constants::API_BASE . '{path}', requirements: Constants::API_V3_REQUIREMENTS)] + #[ApiRoute(verb: 'OPTIONS', url: '/api/v3/{path}', requirements: ['path' => '.+'])] public function preflightedCors() { parent::preflightedCors(); } @@ -112,47 +124,56 @@ public function preflightedCors() { // API v3 methods // Forms /** - * Read Form-List of owned forms - * Return only with necessary information for Listing. - * @return DataResponse + * Get all forms available to the user (owned/shared) + * + * @param string $type The type of forms to retrieve. Defaults to `owned`. + * Possible values: + * - `owned`: Forms owned by the user. + * - `shared`: Forms shared with the user. + * @return DataResponse, array{}> + * @throws OCSBadRequestException wrong form type supplied + * + * 200: Array containing the partial owned or shared forms */ #[CORS()] #[NoAdminRequired()] - #[ApiRoute(verb: 'GET', url: Constants::API_BASE . 'forms', requirements: Constants::API_V3_REQUIREMENTS)] + #[ApiRoute(verb: 'GET', url: '/api/v3/forms')] public function getForms(string $type = 'owned'): DataResponse { + $result = []; + if ($type === 'owned') { $forms = $this->formMapper->findAllByOwnerId($this->currentUser->getUID()); - $result = []; foreach ($forms as $form) { $result[] = $this->formsService->getPartialFormArray($form); } - return new DataResponse($result); } elseif ($type === 'shared') { $forms = $this->formsService->getSharedForms($this->currentUser); $result = array_values(array_map(fn (Form $form): array => $this->formsService->getPartialFormArray($form), $forms)); - return new DataResponse($result); } else { - throw new OCSBadRequestException(); + throw new OCSBadRequestException('wrong form type supplied'); } + + return new DataResponse($result, Http::STATUS_OK); } /** - * Create a new Form and return the Form to edit. - * Return a cloned Form if the parameter $fromId is set + * Create a new form and return the form + * Return a copy of the form if the parameter $fromId is set + * + * @param ?int $fromId (optional) Id of the form that should be cloned + * @return DataResponse + * @throws OCSForbiddenException The user is not allowed to create forms * - * @param int $fromId (optional) ID of the Form that should be cloned - * @return DataResponse - * @throws OCSBadRequestException - * @throws OCSForbiddenException + * 201: the created form */ #[CORS()] #[NoAdminRequired()] - #[ApiRoute(verb: 'POST', url: Constants::API_BASE . 'forms', requirements: Constants::API_V3_REQUIREMENTS)] + #[ApiRoute(verb: 'POST', url: '/api/v3/forms')] public function newForm(?int $fromId = null): DataResponse { // Check if user is allowed if (!$this->configService->canCreateForms()) { $this->logger->debug('This user is not allowed to create Forms.'); - throw new OCSForbiddenException(); + throw new OCSForbiddenException('This user is not allowed to create Forms.'); } if ($fromId === null) { @@ -209,50 +230,56 @@ public function newForm(?int $fromId = null): DataResponse { } } } - return $this->getForm($form->getId()); + + return new DataResponse($this->formsService->getForm($form), Http::STATUS_CREATED); } /** - * Read all information to edit a Form (form, questions, options, except submissions/answers). + * Read all information to edit a Form (form, questions, options, except submissions/answers) * * @param int $formId Id of the form - * @return DataResponse - * @throws OCSBadRequestException - * @throws OCSForbiddenException + * @return DataResponse + * @throws OCSBadRequestException Could not find form + * @throws OCSForbiddenException User has no permissions to get this form + * + * 200: the requested form */ #[CORS()] #[NoAdminRequired()] - #[ApiRoute(verb: 'GET', url: Constants::API_BASE . 'forms/{formId}', requirements: Constants::API_V3_REQUIREMENTS)] + #[ApiRoute(verb: 'GET', url: '/api/v3/forms/{formId}')] public function getForm(int $formId): DataResponse { try { $form = $this->formMapper->findById($formId); } catch (IMapperException $e) { $this->logger->debug('Could not find form'); - throw new OCSBadRequestException(); + throw new OCSBadRequestException('Could not find form'); } if (!$this->formsService->hasUserAccess($form)) { $this->logger->debug('User has no permissions to get this form'); - throw new OCSForbiddenException(); + throw new OCSForbiddenException('User has no permissions to get this form'); } - $formData = $this->formsService->getForm($form); - - return new DataResponse($formData); + return new DataResponse($this->formsService->getForm($form)); } /** - * Writes the given key-value pairs into Database. + * Writes the given key-value pairs into Database * * @param int $formId FormId of form to update - * @param array $keyValuePairs Array of key=>value pairs to update. - * @return DataResponse - * @throws OCSBadRequestException - * @throws OCSForbiddenException + * @param array $keyValuePairs Array of key=>value pairs to update. + * @return DataResponse + * @throws OCSBadRequestException Could not find new form owner + * @throws OCSForbiddenException Empty keyValuePairs provided + * @throws OCSForbiddenException Not allowed to update id, hash, created, fileId or lastUpdated. OwnerId only allowed if no other key provided. + * @throws OCSForbiddenException User is not allowed to modify the form + * @throws OCSNotFoundException Form not found + * + * 200: the id of the updated form */ #[CORS()] #[NoAdminRequired()] - #[ApiRoute(verb: 'PATCH', url: Constants::API_BASE . 'forms/{formId}', requirements: Constants::API_V3_REQUIREMENTS)] + #[ApiRoute(verb: 'PATCH', url: '/api/v3/forms/{formId}')] public function updateForm(int $formId, array $keyValuePairs): DataResponse { $this->logger->debug('Updating form: formId: {formId}, values: {keyValuePairs}', [ 'formId' => $formId, @@ -264,7 +291,7 @@ public function updateForm(int $formId, array $keyValuePairs): DataResponse { // Don't allow empty array if (sizeof($keyValuePairs) === 0) { $this->logger->info('Empty keyValuePairs, will not update.'); - throw new OCSForbiddenException(); + throw new OCSForbiddenException('Empty keyValuePairs, will not update.'); } // Process owner transfer @@ -296,7 +323,7 @@ public function updateForm(int $formId, array $keyValuePairs): DataResponse { isset($keyValuePairs['fileId']) || key_exists('lastUpdated', $keyValuePairs) ) { $this->logger->info('Not allowed to update id, hash, ownerId, created, fileId or lastUpdated'); - throw new OCSForbiddenException(); + throw new OCSForbiddenException('Not allowed to update id, hash, ownerId, created, fileId or lastUpdated'); } // Process file linking @@ -308,7 +335,7 @@ public function updateForm(int $formId, array $keyValuePairs): DataResponse { } // Process file unlinking - if (key_exists('fileId', $keyValuePairs) && key_exists('fileFormat', $keyValuePairs) && !isset($keyValuePairs['fileId']) && !isset($keyValuePairs['fileFormat'])) { + if (key_exists('fileId', $keyValuePairs) && key_exists('fileFormat', $keyValuePairs) && !isset($keyValuePairs['fileFormat'])) { $form->setFileId(null); $form->setFileFormat(null); } @@ -333,13 +360,15 @@ public function updateForm(int $formId, array $keyValuePairs): DataResponse { * Delete a form * * @param int $formId the form id - * @return DataResponse - * @throws OCSBadRequestException - * @throws OCSForbiddenException + * @return DataResponse + * @throws OCSForbiddenException User is not allowed to delete the form + * @throws OCSNotFoundException Form not found + * + * 200: the id of the deleted form */ #[CORS()] #[NoAdminRequired()] - #[ApiRoute(verb: 'DELETE', url: Constants::API_BASE . 'forms/{formId}', requirements: Constants::API_V3_REQUIREMENTS)] + #[ApiRoute(verb: 'DELETE', url: '/api/v3/forms/{formId}')] public function deleteForm(int $formId): DataResponse { $this->logger->debug('Delete Form: {formId}', [ 'formId' => $formId, @@ -355,25 +384,27 @@ public function deleteForm(int $formId): DataResponse { /** * Read all questions (including options) * - * @param int $formId FormId - * @return DataResponse - * @throws OCSBadRequestException - * @throws OCSForbiddenException + * @param int $formId the form id + * @return DataResponse, array{}> + * @throws OCSForbiddenException User has no permissions to get this form + * @throws OCSNotFoundException Could not find form + * + * 200: the questions of the given form */ #[CORS()] #[NoAdminRequired()] - #[ApiRoute(verb: 'GET', url: Constants::API_BASE . 'forms/{formId}/questions', requirements: Constants::API_V3_REQUIREMENTS)] + #[ApiRoute(verb: 'GET', url: '/api/v3/forms/{formId}/questions')] public function getQuestions(int $formId): DataResponse { try { $form = $this->formMapper->findById($formId); } catch (IMapperException $e) { $this->logger->debug('Could not find form'); - throw new OCSBadRequestException(); + throw new OCSNotFoundException('Could not find form'); } if (!$this->formsService->hasUserAccess($form)) { $this->logger->debug('User has no permissions to get this form'); - throw new OCSForbiddenException(); + throw new OCSForbiddenException('User has no permissions to get this form'); } $questionData = $this->formsService->getQuestions($formId); @@ -386,30 +417,36 @@ public function getQuestions(int $formId): DataResponse { * * @param int $formId FormId * @param int $questionId QuestionId - * @return DataResponse - * @throws OCSBadRequestException - * @throws OCSForbiddenException + * @return DataResponse + * @throws OCSBadRequestException Question doesn\'t belong to given Form + * @throws OCSForbiddenException User has no permissions to get this form + * @throws OCSNotFoundException Could not find form + * + * 200: the requested question */ #[CORS()] #[NoAdminRequired()] - #[ApiRoute(verb: 'GET', url: Constants::API_BASE . 'forms/{formId}/questions/{questionId}', requirements: Constants::API_V3_REQUIREMENTS)] + #[ApiRoute(verb: 'GET', url: '/api/v3/forms/{formId}/questions/{questionId}')] public function getQuestion(int $formId, int $questionId): DataResponse { try { $form = $this->formMapper->findById($formId); } catch (IMapperException $e) { $this->logger->debug('Could not find form'); - throw new OCSBadRequestException(); + throw new OCSNotFoundException('Could not find form'); } if (!$this->formsService->hasUserAccess($form)) { $this->logger->debug('User has no permissions to get this form'); - throw new OCSForbiddenException(); + throw new OCSForbiddenException('User has no permissions to get this form'); } $question = $this->formsService->getQuestion($questionId); + if ($question === null) { + throw new OCSNotFoundException('Question doesn\'t exist'); + } if ($question['formId'] !== $formId) { - throw new OCSBadRequestException('Question doesn\'t belong to given Form'); + throw new OCSBadRequestException('Question doesn\'t belong to given form'); } return new DataResponse($question); @@ -419,21 +456,27 @@ public function getQuestion(int $formId, int $questionId): DataResponse { * Add a new question * * @param int $formId the form id - * @param string $type the new question type + * @param FormsQuestionType $type the new question type * @param string $text the new question title - * @param int $fromId (optional) id of the question that should be cloned - * @return DataResponse - * @throws OCSBadRequestException - * @throws OCSForbiddenException + * @param ?int $fromId (optional) id of the question that should be cloned + * @return DataResponse + * @throws OCSBadRequestException Invalid type + * @throws OCSBadRequestException Datetime question type no longer supported + * @throws OCSForbiddenException User has no permissions to get this form + * @throws OCSForbiddenException This form is archived and can not be modified + * @throws OCSNotFoundException Could not find form + * @throws OCSNotFoundException Could not find question + * + * 201: the created question */ #[CORS()] #[NoAdminRequired()] - #[ApiRoute(verb: 'POST', url: Constants::API_BASE . 'forms/{formId}/questions', requirements: Constants::API_V3_REQUIREMENTS)] + #[ApiRoute(verb: 'POST', url: '/api/v3/forms/{formId}/questions')] public function newQuestion(int $formId, ?string $type = null, string $text = '', ?int $fromId = null): DataResponse { $form = $this->getFormIfAllowed($formId); if ($this->formsService->isFormArchived($form)) { $this->logger->debug('This form is archived and can not be modified'); - throw new OCSForbiddenException(); + throw new OCSForbiddenException('This form is archived and can not be modified'); } if ($fromId === null) { @@ -475,7 +518,10 @@ public function newQuestion(int $formId, ?string $type = null, string $text = '' $question = $this->questionMapper->insert($question); - $response = $question->read(); + $response = $this->formsService->getQuestion($question->getId()); + if ($response === null) { + throw new OCSException('Failed to create question'); + } $response['options'] = []; $response['accept'] = []; } else { @@ -518,23 +564,32 @@ public function newQuestion(int $formId, ?string $type = null, string $text = '' $this->formMapper->update($form); - return new DataResponse($response); + return new DataResponse($response, Http::STATUS_CREATED); } /** - * Writes the given key-value pairs into Database. - * Key 'order' should only be changed by reorderQuestions() and is not allowed here. + * Writes the given key-value pairs into Database + * Key `order` should only be changed by reorderQuestions() and is not allowed here * * @param int $formId the form id * @param int $questionId id of question to update - * @param array $keyValuePairs Array of key=>value pairs to update. - * @return DataResponse - * @throws OCSBadRequestException - * @throws OCSForbiddenException + * @param array $keyValuePairs Array of key=>value pairs to update. + * @return DataResponse + * @throws OCSBadRequestException Question doesn\'t belong to given Form + * @throws OCSBadRequestException Invalid extraSettings, will not update. + * @throws OCSForbiddenException Empty keyValuePairs, will not update + * @throws OCSForbiddenException Not allowed to update `id` or `formId` + * @throws OCSForbiddenException Please use reorderQuestions() to change order + * @throws OCSForbiddenException This form is archived and can not be modified + * @throws OCSForbiddenException User has no permissions to get this form + * @throws OCSNotFoundException Could not find form + * @throws OCSNotFoundException Could not find question + * + * 200: the id of the updated question */ #[CORS()] #[NoAdminRequired()] - #[ApiRoute(verb: 'PATCH', url: Constants::API_BASE . 'forms/{formId}/questions/{questionId}', requirements: Constants::API_V3_REQUIREMENTS)] + #[ApiRoute(verb: 'PATCH', url: '/api/v3/forms/{formId}/questions/{questionId}')] public function updateQuestion(int $formId, int $questionId, array $keyValuePairs): DataResponse { $this->logger->debug('Updating question: formId: {formId}, questionId: {questionId}, values: {keyValuePairs}', [ 'formId' => $formId, @@ -546,7 +601,7 @@ public function updateQuestion(int $formId, int $questionId, array $keyValuePair $question = $this->questionMapper->findById($questionId); } catch (IMapperException $e) { $this->logger->debug('Could not find question'); - throw new OCSBadRequestException('Could not find question'); + throw new OCSNotFoundException('Could not find question'); } if ($question->getFormId() !== $formId) { @@ -556,19 +611,19 @@ public function updateQuestion(int $formId, int $questionId, array $keyValuePair $form = $this->getFormIfAllowed($formId); if ($this->formsService->isFormArchived($form)) { $this->logger->debug('This form is archived and can not be modified'); - throw new OCSForbiddenException(); + throw new OCSForbiddenException('This form is archived and can not be modified'); } // Don't allow empty array if (sizeof($keyValuePairs) === 0) { $this->logger->info('Empty keyValuePairs, will not update.'); - throw new OCSForbiddenException(); + throw new OCSBadRequestException('This form is archived and can not be modified'); } //Don't allow to change id or formId if (key_exists('id', $keyValuePairs) || key_exists('formId', $keyValuePairs)) { $this->logger->debug('Not allowed to update \'id\' or \'formId\''); - throw new OCSForbiddenException(); + throw new OCSForbiddenException('Not allowed to update \'id\' or \'formId\''); } // Don't allow to reorder here @@ -597,13 +652,18 @@ public function updateQuestion(int $formId, int $questionId, array $keyValuePair * * @param int $formId the form id * @param int $questionId the question id - * @return DataResponse - * @throws OCSBadRequestException - * @throws OCSForbiddenException + * @return DataResponse + * @throws OCSBadRequestException Question doesn\'t belong to given Form + * @throws OCSForbiddenException This form is archived and can not be modified + * @throws OCSForbiddenException User has no permissions to get this form + * @throws OCSNotFoundException Could not find form + * @throws OCSNotFoundException Could not find question + * + * 200: the id of the deleted question */ #[CORS()] #[NoAdminRequired()] - #[ApiRoute(verb: 'DELETE', url: Constants::API_BASE . 'forms/{formId}/questions/{questionId}', requirements: Constants::API_V3_REQUIREMENTS)] + #[ApiRoute(verb: 'DELETE', url: '/api/v3/forms/{formId}/questions/{questionId}')] public function deleteQuestion(int $formId, int $questionId): DataResponse { $this->logger->debug('Mark question as deleted: {questionId}', [ 'questionId' => $questionId, @@ -613,7 +673,7 @@ public function deleteQuestion(int $formId, int $questionId): DataResponse { $question = $this->questionMapper->findById($questionId); } catch (IMapperException $e) { $this->logger->debug('Could not find question'); - throw new OCSBadRequestException('Could not find question'); + throw new OCSNotFoundException('Could not find question'); } if ($question->getFormId() !== $formId) { @@ -623,7 +683,7 @@ public function deleteQuestion(int $formId, int $questionId): DataResponse { $form = $this->getFormIfAllowed($formId); if ($this->formsService->isFormArchived($form)) { $this->logger->debug('This form is archived and can not be modified'); - throw new OCSForbiddenException(); + throw new OCSForbiddenException('This form is archived and can not be modified'); } // Store Order of deleted Question @@ -649,17 +709,25 @@ public function deleteQuestion(int $formId, int $questionId): DataResponse { } /** - * Updates the Order of all Questions of a Form. + * Updates the Order of all Questions of a Form * * @param int $formId Id of the form to reorder - * @param Array $newOrder Array of Question-Ids in new order. - * @return DataResponse - * @throws OCSBadRequestException - * @throws OCSForbiddenException + * @param list $newOrder Array of Question-Ids in new order. + * @return DataResponse, array{}> + * @throws OCSBadRequestException The given array contains duplicates + * @throws OCSBadRequestException The length of the given array does not match the number of stored questions + * @throws OCSBadRequestException Question doesn't belong to given Form + * @throws OCSBadRequestException One question has already been marked as deleted + * @throws OCSForbiddenException This form is archived and can not be modified + * @throws OCSForbiddenException User has no permissions to get this form + * @throws OCSNotFoundException Could not find form + * @throws OCSNotFoundException Could not find question + * + * 200: the question ids of the given form in the new order */ #[CORS()] #[NoAdminRequired()] - #[ApiRoute(verb: 'PATCH', url: Constants::API_BASE . 'forms/{formId}/questions', requirements: Constants::API_V3_REQUIREMENTS)] + #[ApiRoute(verb: 'PATCH', url: '/api/v3/forms/{formId}/questions')] public function reorderQuestions(int $formId, array $newOrder): DataResponse { $this->logger->debug('Reordering Questions on Form {formId} as Question-Ids {newOrder}', [ 'formId' => $formId, @@ -669,7 +737,7 @@ public function reorderQuestions(int $formId, array $newOrder): DataResponse { $form = $this->getFormIfAllowed($formId); if ($this->formsService->isFormArchived($form)) { $this->logger->debug('This form is archived and can not be modified'); - throw new OCSForbiddenException(); + throw new OCSForbiddenException('This form is archived and can not be modified'); } // Check if array contains duplicates @@ -693,27 +761,27 @@ public function reorderQuestions(int $formId, array $newOrder): DataResponse { try { $questions[$arrayKey] = $this->questionMapper->findById($questionId); } catch (IMapperException $e) { - $this->logger->debug('Could not find question. Id: {questionId}', [ + $this->logger->debug('Could not find question {questionId}', [ 'questionId' => $questionId ]); - throw new OCSBadRequestException(); + throw new OCSNotFoundException('Could not find question'); } // Abort if a question is not part of the Form. if ($questions[$arrayKey]->getFormId() !== $formId) { - $this->logger->debug('This Question is not part of the given Form: questionId: {questionId}', [ + $this->logger->debug('This Question is not part of the given form: {questionId}', [ 'questionId' => $questionId ]); - throw new OCSBadRequestException(); + throw new OCSBadRequestException('Question doesn\'t belong to given Form'); } // Abort if a question is already marked as deleted (order==0) $oldOrder = $questions[$arrayKey]->getOrder(); if ($oldOrder === 0) { - $this->logger->debug('This Question has already been marked as deleted: Id: {questionId}', [ + $this->logger->debug('This question has already been marked as deleted: Id: {questionId}', [ 'questionId' => $questions[$arrayKey]->getId() ]); - throw new OCSBadRequestException(); + throw new OCSBadRequestException('One question has already been marked as deleted'); } // Only set order, if it changed. @@ -727,7 +795,7 @@ public function reorderQuestions(int $formId, array $newOrder): DataResponse { foreach ($questions as $question) { $this->questionMapper->update($question); - $response[$question->getId()] = [ + $response[(string)$question->getId()] = [ 'order' => $question->getOrder() ]; } @@ -744,14 +812,19 @@ public function reorderQuestions(int $formId, array $newOrder): DataResponse { * * @param int $formId id of the form * @param int $questionId id of the question - * @param array $optionTexts the new option text - * @return DataResponse Returns a DataResponse containing the added options - * @throws OCSBadRequestException - * @throws OCSForbiddenException + * @param list $optionTexts the new option text + * @return DataResponse, array{}> Returns a DataResponse containing the added options + * @throws OCSBadRequestException This question is not part ot the given form + * @throws OCSForbiddenException This form is archived and can not be modified + * @throws OCSForbiddenException Current user has no permission to edit + * @throws OCSNotFoundException Could not find form + * @throws OCSNotFoundException Could not find question + * + * 201: the created option */ #[CORS()] #[NoAdminRequired()] - #[ApiRoute(verb: 'POST', url: Constants::API_BASE . 'forms/{formId}/questions/{questionId}/options', requirements: Constants::API_V3_REQUIREMENTS)] + #[ApiRoute(verb: 'POST', url: '/api/v3/forms/{formId}/questions/{questionId}/options')] public function newOption(int $formId, int $questionId, array $optionTexts): DataResponse { $this->logger->debug('Adding new options: formId: {formId}, questionId: {questionId}, text: {text}', [ 'formId' => $formId, @@ -762,21 +835,21 @@ public function newOption(int $formId, int $questionId, array $optionTexts): Dat $form = $this->getFormIfAllowed($formId); if ($this->formsService->isFormArchived($form)) { $this->logger->debug('This form is archived and can not be modified'); - throw new OCSForbiddenException(); + throw new OCSForbiddenException('This form is archived and can not be modified'); } - + try { $question = $this->questionMapper->findById($questionId); } catch (IMapperException $e) { $this->logger->debug('Could not find question'); - throw new OCSBadRequestException('Could not find question'); + throw new OCSNotFoundException('Could not find question'); } if ($question->getFormId() !== $formId) { - $this->logger->debug('This Question is not part of the given Form: questionId: {questionId}', [ + $this->logger->debug('This question is not part of the given form: questionId: {questionId}', [ 'questionId' => $questionId ]); - throw new OCSBadRequestException(); + throw new OCSBadRequestException('This question is not part ot the given form'); } // Retrieve all options sorted by 'order'. Takes the order of the last array-element and adds one. @@ -808,23 +881,30 @@ public function newOption(int $formId, int $questionId, array $optionTexts): Dat $this->formMapper->update($form); - return new DataResponse($addedOptions); + return new DataResponse($addedOptions, Http::STATUS_CREATED); } /** - * Writes the given key-value pairs into Database. + * Writes the given key-value pairs into Database * * @param int $formId id of form * @param int $questionId id of question * @param int $optionId id of option to update - * @param array $keyValuePairs Array of key=>value pairs to update. - * @return DataResponse - * @throws OCSBadRequestException - * @throws OCSForbiddenException + * @param array $keyValuePairs Array of key=>value pairs to update. + * @return DataResponse Returns the id of the updated option + * @throws OCSBadRequestException The given option id doesn't match the question or form + * @throws OCSForbiddenException This form is archived and can not be modified + * @throws OCSForbiddenException Current user has no permission to edit + * @throws OCSForbiddenException Empty keyValuePairs, will not update + * @throws OCSForbiddenException Not allowed to update id or questionId + * @throws OCSNotFoundException Could not find form + * @throws OCSNotFoundException Could not find option or question + * + * 200: the id of the updated option */ #[CORS()] #[NoAdminRequired()] - #[ApiRoute(verb: 'PATCH', url: Constants::API_BASE . 'forms/{formId}/questions/{questionId}/options/{optionId}', requirements: Constants::API_V3_REQUIREMENTS)] + #[ApiRoute(verb: 'PATCH', url: '/api/v3/forms/{formId}/questions/{questionId}/options/{optionId}')] public function updateOption(int $formId, int $questionId, int $optionId, array $keyValuePairs): DataResponse { $this->logger->debug('Updating option: form: {formId}, question: {questionId}, option: {optionId}, values: {keyValuePairs}', [ 'formId' => $formId, @@ -836,7 +916,7 @@ public function updateOption(int $formId, int $questionId, int $optionId, array $form = $this->getFormIfAllowed($formId); if ($this->formsService->isFormArchived($form)) { $this->logger->debug('This form is archived and can not be modified'); - throw new OCSForbiddenException(); + throw new OCSForbiddenException('This form is archived and can not be modified'); } try { @@ -844,24 +924,24 @@ public function updateOption(int $formId, int $questionId, int $optionId, array $question = $this->questionMapper->findById($questionId); } catch (IMapperException $e) { $this->logger->debug('Could not find option or question'); - throw new OCSBadRequestException('Could not find option or question'); + throw new OCSNotFoundException('Could not find option or question'); } if ($option->getQuestionId() !== $questionId || $question->getFormId() !== $formId) { $this->logger->debug('The given option id doesn\'t match the question or form.'); - throw new OCSBadRequestException(); + throw new OCSBadRequestException('The given option id doesn\'t match the question or form.'); } // Don't allow empty array if (sizeof($keyValuePairs) === 0) { - $this->logger->info('Empty keyValuePairs, will not update.'); - throw new OCSForbiddenException(); + $this->logger->info('Empty keyValuePairs, will not update'); + throw new OCSForbiddenException('Empty keyValuePairs, will not update'); } //Don't allow to change id or questionId if (key_exists('id', $keyValuePairs) || key_exists('questionId', $keyValuePairs)) { $this->logger->debug('Not allowed to update id or questionId'); - throw new OCSForbiddenException(); + throw new OCSForbiddenException('Not allowed to update id or questionId'); } // Create OptionEntity with given Params & Id. @@ -882,13 +962,18 @@ public function updateOption(int $formId, int $questionId, int $optionId, array * @param int $formId id of form * @param int $questionId id of question * @param int $optionId id of option to update - * @return DataResponse - * @throws OCSBadRequestException - * @throws OCSForbiddenException + * @return DataResponse Returns the id of the deleted option + * @throws OCSBadRequestException The given option id doesn't match the question or form + * @throws OCSForbiddenException This form is archived and can not be modified + * @throws OCSForbiddenException Current user has no permission to edit + * @throws OCSNotFoundException Could not find form + * @throws OCSNotFoundException Could not find question or option + * + * 200: the id of the deleted option */ #[CORS()] #[NoAdminRequired()] - #[ApiRoute(verb: 'DELETE', url: Constants::API_BASE . 'forms/{formId}/questions/{questionId}/options/{optionId}', requirements: Constants::API_V3_REQUIREMENTS)] + #[ApiRoute(verb: 'DELETE', url: '/api/v3/forms/{formId}/questions/{questionId}/options/{optionId}')] public function deleteOption(int $formId, int $questionId, int $optionId): DataResponse { $this->logger->debug('Deleting option: {optionId}', [ 'optionId' => $optionId @@ -897,20 +982,20 @@ public function deleteOption(int $formId, int $questionId, int $optionId): DataR $form = $this->getFormIfAllowed($formId); if ($this->formsService->isFormArchived($form)) { $this->logger->debug('This form is archived and can not be modified'); - throw new OCSForbiddenException(); + throw new OCSForbiddenException('This form is archived and can not be modified'); } - + try { $option = $this->optionMapper->findById($optionId); $question = $this->questionMapper->findById($questionId); } catch (IMapperException $e) { - $this->logger->debug('Could not find form, question or option'); - throw new OCSBadRequestException('Could not find form, question or option'); + $this->logger->debug('Could not find option or question'); + throw new OCSBadRequestException('Could not find option or question'); } if ($option->getQuestionId() !== $questionId || $question->getFormId() !== $formId) { $this->logger->debug('The given option id doesn\'t match the question or form.'); - throw new OCSBadRequestException(); + throw new OCSBadRequestException('The given option id doesn\'t match the question or form.'); } $this->optionMapper->delete($option); @@ -932,28 +1017,39 @@ public function deleteOption(int $formId, int $questionId, int $optionId): DataR * Reorder options for a given question * @param int $formId id of form * @param int $questionId id of question - * @param Array $newOrder Order to use + * @param list $newOrder Array of option ids in new order. + * @return DataResponse, array{}> + * @throws OCSBadRequestException The given question id doesn't match the form + * @throws OCSBadRequestException The given array contains duplicates + * @throws OCSBadRequestException The length of the given array does not match the number of stored options + * @throws OCSBadRequestException This option is not part of the given question + * @throws OCSForbiddenException This form is archived and can not be modified + * @throws OCSForbiddenException Current user has no permission to edit + * @throws OCSNotFoundException Could not find form + * @throws OCSNotFoundException Could not find question + * + * 200: the options of the question in the new order */ #[CORS()] #[NoAdminRequired()] - #[ApiRoute(verb: 'PATCH', url: Constants::API_BASE . 'forms/{formId}/questions/{questionId}/options', requirements: Constants::API_V3_REQUIREMENTS)] + #[ApiRoute(verb: 'PATCH', url: '/api/v3/forms/{formId}/questions/{questionId}/options')] public function reorderOptions(int $formId, int $questionId, array $newOrder) { $form = $this->getFormIfAllowed($formId); if ($this->formsService->isFormArchived($form)) { $this->logger->debug('This form is archived and can not be modified'); - throw new OCSForbiddenException(); + throw new OCSForbiddenException('This form is archived and can not be modified'); } try { $question = $this->questionMapper->findById($questionId); } catch (IMapperException $e) { - $this->logger->debug('Could not find form or question', ['exception' => $e]); - throw new OCSNotFoundException('Could not find form or question'); + $this->logger->debug('Could not find question'); + throw new OCSNotFoundException('Could not find question'); } if ($question->getFormId() !== $formId) { $this->logger->debug('The given question id doesn\'t match the form.'); - throw new OCSBadRequestException(); + throw new OCSBadRequestException('The given question id doesn\'t match the form.'); } // Check if array contains duplicates @@ -961,14 +1057,14 @@ public function reorderOptions(int $formId, int $questionId, array $newOrder) { $this->logger->debug('The given array contains duplicates'); throw new OCSBadRequestException('The given array contains duplicates'); } - + $options = $this->optionMapper->findByQuestion($questionId); - + if (sizeof($options) !== sizeof($newOrder)) { $this->logger->debug('The length of the given array does not match the number of stored options'); throw new OCSBadRequestException('The length of the given array does not match the number of stored options'); } - + $options = []; // Clear Array of Entities $response = []; // Array of ['optionId' => ['order' => newOrder]] @@ -980,18 +1076,17 @@ public function reorderOptions(int $formId, int $questionId, array $newOrder) { $this->logger->debug('Could not find option. Id: {optionId}', [ 'optionId' => $optionId ]); - throw new OCSBadRequestException(); + throw new OCSNotFoundException('Could not find option'); } - // Abort if a question is not part of the Form. + // Abort if a option is not part of the question. if ($options[$arrayKey]->getQuestionId() !== $questionId) { - $this->logger->debug('This Option is not part of the given Question: formId: {formId}', [ + $this->logger->debug('This option is not part of the given question: formId: {formId}', [ 'formId' => $formId ]); - throw new OCSBadRequestException(); + throw new OCSBadRequestException('This option is not part of the given question'); } - // Abort if a question is already marked as deleted (order==0) $oldOrder = $options[$arrayKey]->getOrder(); // Only set order, if it changed. @@ -1005,13 +1100,13 @@ public function reorderOptions(int $formId, int $questionId, array $newOrder) { foreach ($options as $option) { $this->optionMapper->update($option); - $response[$option->getId()] = [ + $response[(string)$option->getId()] = [ 'order' => $option->getOrder() ]; } $this->formMapper->update($form); - + return new DataResponse($response); } @@ -1021,24 +1116,31 @@ public function reorderOptions(int $formId, int $questionId, array $newOrder) { * Get all the submissions of a given form * * @param int $formId of the form - * @return DataResponse|DataDownloadResponse - * @throws OCSNotFoundException - * @throws OCSForbiddenException + * @param ?string $fileFormat the file format that should be used for the download. Defaults to `null` + * Possible values: + * - `csv`: Comma-separated value + * - `ods`: OpenDocument Spreadsheet + * - `xlsx`: Excel Open XML Spreadsheet + * @return DataResponse|DataDownloadResponse + * @throws OCSNotFoundException Could not find form + * @throws OCSForbiddenException The current user has no permission to get the results for this form + * + * 200: the submissions of the form */ #[CORS()] #[NoAdminRequired()] - #[ApiRoute(verb: 'GET', url: Constants::API_BASE . 'forms/{formId}/submissions', requirements: Constants::API_V3_REQUIREMENTS)] + #[ApiRoute(verb: 'GET', url: '/api/v3/forms/{formId}/submissions')] public function getSubmissions(int $formId, ?string $fileFormat = null): DataResponse|DataDownloadResponse { try { $form = $this->formMapper->findById($formId); } catch (IMapperException $e) { $this->logger->debug('Could not find form'); - throw new OCSNotFoundException(); + throw new OCSNotFoundException('Could not find form'); } if (!$this->formsService->canSeeResults($form)) { $this->logger->debug('The current user has no permission to get the results for this form'); - throw new OCSForbiddenException(); + throw new OCSForbiddenException('The current user has no permission to get the results for this form'); } if ($fileFormat !== null) { @@ -1053,22 +1155,23 @@ public function getSubmissions(int $formId, ?string $fileFormat = null): DataRes $questions = $this->formsService->getQuestions($formId); // Append Display Names - foreach ($submissions as $key => $submission) { + $submissions = array_map(function (array $submission) { if (substr($submission['userId'], 0, 10) === 'anon-user-') { // Anonymous User // TRANSLATORS On Results when listing the single Responses to the form, this text is shown as heading of the Response. - $submissions[$key]['userDisplayName'] = $this->l10n->t('Anonymous response'); + $submission['userDisplayName'] = $this->l10n->t('Anonymous response'); } else { $userEntity = $this->userManager->get($submission['userId']); if ($userEntity instanceof IUser) { - $submissions[$key]['userDisplayName'] = $userEntity->getDisplayName(); + $submission['userDisplayName'] = $userEntity->getDisplayName(); } else { // Fallback, should not occur regularly. - $submissions[$key]['userDisplayName'] = $submission['userId']; + $submission['userDisplayName'] = $submission['userId']; } } - } + return $submission; + }, $submissions); $response = [ 'submissions' => $submissions, @@ -1082,13 +1185,15 @@ public function getSubmissions(int $formId, ?string $fileFormat = null): DataRes * Delete all submissions of a specified form * * @param int $formId the form id - * @return DataResponse - * @throws OCSBadRequestException - * @throws OCSForbiddenException + * @return DataResponse + * @throws OCSNotFoundException Could not find form + * @throws OCSForbiddenException This form is not owned by the current user and user has no `results_delete` permission + * + * 200: the form id of the deleted submission */ #[CORS()] #[NoAdminRequired()] - #[ApiRoute(verb: 'DELETE', url: Constants::API_BASE . 'forms/{formId}/submissions', requirements: Constants::API_V3_REQUIREMENTS)] + #[ApiRoute(verb: 'DELETE', url: '/api/v3/forms/{formId}/submissions')] public function deleteAllSubmissions(int $formId): DataResponse { $this->logger->debug('Delete all submissions to form: {formId}', [ 'formId' => $formId, @@ -1098,13 +1203,13 @@ public function deleteAllSubmissions(int $formId): DataResponse { $form = $this->formMapper->findById($formId); } catch (IMapperException $e) { $this->logger->debug('Could not find form'); - throw new OCSBadRequestException(); + throw new OCSNotFoundException('Could not find form'); } // The current user has permissions to remove submissions if (!$this->formsService->canDeleteResults($form)) { $this->logger->debug('This form is not owned by the current user and user has no `results_delete` permission'); - throw new OCSForbiddenException(); + throw new OCSForbiddenException('This form is not owned by the current user and user has no `results_delete` permission'); } // Delete all submissions (incl. Answers) @@ -1118,17 +1223,23 @@ public function deleteAllSubmissions(int $formId): DataResponse { * Process a new submission * * @param int $formId the form id - * @param array $answers [question_id => arrayOfString] + * @param array> $answers [question_id => arrayOfString] * @param string $shareHash public share-hash -> Necessary to submit on public link-shares. - * @return DataResponse - * @throws OCSBadRequestException - * @throws OCSForbiddenException + * @return DataResponse + * @throws OCSBadRequestException At least one submitted answer is not valid + * @throws OCSForbiddenException Already submitted + * @throws OCSForbiddenException Not allowed to access this form + * @throws OCSForbiddenException This form is no longer taking answers + * @throws OCSForbiddenException This form is not owned by the current user and user has no `results_delete` permission + * @throws OCSNotFoundException Could not find form + * + * 201: empty response */ #[CORS()] #[NoAdminRequired()] #[NoCSRFRequired()] #[PublicPage()] - #[ApiRoute(verb: 'POST', url: Constants::API_BASE . 'forms/{formId}/submissions', requirements: Constants::API_V3_REQUIREMENTS)] + #[ApiRoute(verb: 'POST', url: '/api/v3/forms/{formId}/submissions')] public function newSubmission(int $formId, array $answers, string $shareHash = ''): DataResponse { $this->logger->debug('Inserting submission: formId: {formId}, answers: {answers}, shareHash: {shareHash}', [ 'formId' => $formId, @@ -1207,7 +1318,7 @@ public function newSubmission(int $formId, array $answers, string $shareHash = ' } } - return new DataResponse(); + return new DataResponse(null, Http::STATUS_CREATED); } /** @@ -1215,13 +1326,16 @@ public function newSubmission(int $formId, array $answers, string $shareHash = ' * * @param int $formId the form id * @param int $submissionId the submission id - * @return DataResponse - * @throws OCSBadRequestException - * @throws OCSForbiddenException + * @return DataResponse + * @throws OCSBadRequestException Submission doesn't belong to given form + * @throws OCSNotFoundException Could not find form or submission + * @throws OCSForbiddenException This form is not owned by the current user and user has no `results_delete` permission + * + * 200: the id of the deleted submission */ #[CORS()] #[NoAdminRequired()] - #[ApiRoute(verb: 'DELETE', url: Constants::API_BASE . 'forms/{formId}/submissions/{submissionId}', requirements: Constants::API_V3_REQUIREMENTS)] + #[ApiRoute(verb: 'DELETE', url: '/api/v3/forms/{formId}/submissions/{submissionId}')] public function deleteSubmission(int $formId, int $submissionId): DataResponse { $this->logger->debug('Delete Submission: {submissionId}', [ 'submissionId' => $submissionId, @@ -1232,7 +1346,7 @@ public function deleteSubmission(int $formId, int $submissionId): DataResponse { $form = $this->formMapper->findById($formId); } catch (IMapperException $e) { $this->logger->debug('Could not find form or submission'); - throw new OCSBadRequestException(); + throw new OCSNotFoundException('Could not find form or submission'); } if ($formId !== $submission->getFormId()) { @@ -1243,7 +1357,7 @@ public function deleteSubmission(int $formId, int $submissionId): DataResponse { // The current user has permissions to remove submissions if (!$this->formsService->canDeleteResults($form)) { $this->logger->debug('This form is not owned by the current user and user has no `results_delete` permission'); - throw new OCSForbiddenException(); + throw new OCSForbiddenException('This form is not owned by the current user and user has no `results_delete` permission'); } // Delete submission (incl. Answers) @@ -1259,24 +1373,26 @@ public function deleteSubmission(int $formId, int $submissionId): DataResponse { * @param int $formId of the form * @param string $path The Cloud-Path to export to * @param string $fileFormat File format used for export - * @return DataResponse - * @throws OCSBadRequestException - * @throws OCSForbiddenException + * @return DataResponse + * @throws OCSForbiddenException The current user has no permission to get the results for this form + * @throws OCSNotFoundException Could not find form + * + * 200: the file name used for storing the submissions */ #[CORS()] #[NoAdminRequired()] - #[ApiRoute(verb: 'POST', url: Constants::API_BASE . 'forms/{formId}/submissions/export', requirements: Constants::API_V3_REQUIREMENTS)] + #[ApiRoute(verb: 'POST', url: '/api/v3/forms/{formId}/submissions/export')] public function exportSubmissionsToCloud(int $formId, string $path, string $fileFormat = Constants::DEFAULT_FILE_FORMAT) { try { $form = $this->formMapper->findById($formId); } catch (IMapperException $e) { $this->logger->debug('Could not find form'); - throw new OCSNotFoundException(); + throw new OCSNotFoundException('Could not find form'); } if (!$this->formsService->canSeeResults($form)) { $this->logger->debug('The current user has no permission to get the results for this form'); - throw new OCSForbiddenException(); + throw new OCSForbiddenException('The current user has no permission to get the results for this form'); } $file = $this->submissionService->writeFileToCloud($form, $path, $fileFormat); @@ -1290,13 +1406,23 @@ public function exportSubmissionsToCloud(int $formId, string $path, string $file * @param int $formId id of the form * @param int $questionId id of the question * @param string $shareHash hash of the form share - * @return Response + * @return DataResponse, array{}> + * @throws OCSBadRequestException No files provided + * @throws OCSBadRequestException Question doesn't belong to the given form + * @throws OCSBadRequestException Invalid file provided + * @throws OCSBadRequestException Failed to upload the file + * @throws OCSBadRequestException File size exceeds the maximum allowed size + * @throws OCSBadRequestException File type is not allowed + * @throws OCSForbiddenException Already submitted + * @throws OCSNotFoundException Could not find question + * + * 200: the file id and name of the uploaded file */ #[CORS()] #[NoAdminRequired()] #[PublicPage()] - #[ApiRoute(verb: 'POST', url: Constants::API_BASE . 'forms/{formId}/submissions/files/{questionId}', requirements: Constants::API_V3_REQUIREMENTS)] - public function uploadFiles(int $formId, int $questionId, string $shareHash = ''): Response { + #[ApiRoute(verb: 'POST', url: '/api/v3/forms/{formId}/submissions/files/{questionId}')] + public function uploadFiles(int $formId, int $questionId, string $shareHash = ''): DataResponse { $this->logger->debug('Uploading files for formId: {formId}, questionId: {questionId}', [ 'formId' => $formId, 'questionId' => $questionId @@ -1325,7 +1451,7 @@ public function uploadFiles(int $formId, int $questionId, string $shareHash = '' $this->logger->debug('Could not find question with id {questionId}', [ 'questionId' => $questionId ]); - throw new OCSBadRequestException(previous: $e instanceof \Exception ? $e : null); + throw new OCSNotFoundException('Could not find question'); } if ($formId !== $question->getFormId()) { @@ -1486,7 +1612,7 @@ private function loadFormForSubmission(int $formId, string $shareHash): Form { $form = $this->formMapper->findById($formId); } catch (IMapperException $e) { $this->logger->debug('Could not find form'); - throw new OCSBadRequestException(previous: $e instanceof \Exception ? $e : null); + throw new OCSNotFoundException('Could not find form'); } // Does the user have access to the form (Either by logged-in user, or by providing public share-hash.) @@ -1530,12 +1656,12 @@ private function getFormIfAllowed(int $formId): Form { $form = $this->formMapper->findById($formId); } catch (IMapperException $e) { $this->logger->debug('Could not find form'); - throw new OCSNotFoundException(); + throw new OCSNotFoundException('Could not find form'); } if ($form->getOwnerId() !== $this->currentUser->getUID()) { $this->logger->debug('This form is not owned by the current user'); - throw new OCSForbiddenException(); + throw new OCSForbiddenException('This form is not owned by the current user'); } return $form; } diff --git a/lib/Controller/ConfigController.php b/lib/Controller/ConfigController.php index e0e7990ef..a4ca79fc7 100644 --- a/lib/Controller/ConfigController.php +++ b/lib/Controller/ConfigController.php @@ -30,12 +30,14 @@ use OCP\AppFramework\ApiController; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\FrontpageRoute; +use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\DataResponse; use OCP\IConfig; use OCP\IRequest; use Psr\Log\LoggerInterface; +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] class ConfigController extends ApiController { public function __construct( protected $appName, diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 4b1d87673..7375fd2b4 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -39,6 +39,7 @@ use OCP\AppFramework\Http\Attribute\FrontpageRoute; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\ContentSecurityPolicy; use OCP\AppFramework\Http\RedirectResponse; @@ -54,6 +55,7 @@ use OCP\IUserSession; use OCP\Util; +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] class PageController extends Controller { private const TEMPLATE_MAIN = 'main'; @@ -170,9 +172,9 @@ public function embeddedFormView(string $hash): Response { Util::addStyle($this->appName, 'embedded'); $response = $this->createPublicSubmitView($form, $hash) ->renderAs(TemplateResponse::RENDER_AS_BASE); - + $this->initialState->provideInitialState('isEmbedded', true); - + return $this->setEmbeddedCSP($response); } diff --git a/lib/Controller/ShareApiController.php b/lib/Controller/ShareApiController.php index 3adba90ca..9e1b270eb 100644 --- a/lib/Controller/ShareApiController.php +++ b/lib/Controller/ShareApiController.php @@ -30,19 +30,21 @@ use OCA\Forms\Db\FormMapper; use OCA\Forms\Db\Share; use OCA\Forms\Db\ShareMapper; +use OCA\Forms\ResponseDefinitions; use OCA\Forms\Service\CirclesService; use OCA\Forms\Service\ConfigService; use OCA\Forms\Service\FormsService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\IMapperException; +use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\Attribute\CORS; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCS\OCSBadRequestException; -use OCP\AppFramework\OCS\OCSException; use OCP\AppFramework\OCS\OCSForbiddenException; +use OCP\AppFramework\OCS\OCSNotFoundException; use OCP\AppFramework\OCSController; use OCP\Files\IRootFolder; use OCP\IGroup; @@ -56,6 +58,9 @@ use OCP\Share\IShare; use Psr\Log\LoggerInterface; +/** + * @psalm-import-type FormsShare from ResponseDefinitions + */ class ShareApiController extends OCSController { private IUser $currentUser; @@ -85,13 +90,29 @@ public function __construct( * @param int $formId The form to share * @param int $shareType Nextcloud-ShareType * @param string $shareWith ID of user/group/... to share with. For Empty shareWith and shareType Link, this will be set as RandomID. - * @return DataResponse - * @throws OCSBadRequestException - * @throws OCSForbiddenException + * @param list $permissions the permissions granted on the share, defaults to `submit` + * Possible values: + * - `submit` user can submit + * - `results` user can see the results + * - `results_delete` user can see and delete results + * @return DataResponse + * @throws OCSBadRequestException Invalid shareType + * @throws OCSBadRequestException Invalid permission given + * @throws OCSBadRequestException Invalid user to share with + * @throws OCSBadRequestException Invalid group to share with + * @throws OCSBadRequestException Invalid team to share with + * @throws OCSBadRequestException Unknown shareType + * @throws OCSBadRequestException Share hash exists, please try again + * @throws OCSBadRequestException Teams app is disabled + * @throws OCSForbiddenException Link share not allowed + * @throws OCSForbiddenException This form is not owned by the current user + * @throws OCSNotFoundException Could not find form + * + * 201: the created share */ #[CORS()] #[NoAdminRequired()] - #[ApiRoute(verb: 'POST', url: Constants::API_BASE . 'forms/{formId}/shares', requirements: Constants::API_V3_REQUIREMENTS)] + #[ApiRoute(verb: 'POST', url: '/api/v3/forms/{formId}/shares')] public function newShare(int $formId, int $shareType, string $shareWith = '', array $permissions = [Constants::PERMISSION_SUBMIT]): DataResponse { $this->logger->debug('Adding new share: formId: {formId}, shareType: {shareType}, shareWith: {shareWith}, permissions: {permissions}', [ 'formId' => $formId, @@ -109,20 +130,20 @@ public function newShare(int $formId, int $shareType, string $shareWith = '', ar // Block LinkShares if not allowed if ($shareType === IShare::TYPE_LINK && !$this->configService->getAllowPublicLink()) { $this->logger->debug('Link Share not allowed.'); - throw new OCSForbiddenException('Link Share not allowed.'); + throw new OCSForbiddenException('Link share not allowed.'); } try { $form = $this->formMapper->findById($formId); } catch (IMapperException $e) { $this->logger->debug('Could not find form', ['exception' => $e]); - throw new OCSBadRequestException('Could not find form'); + throw new OCSNotFoundException('Could not find form'); } // Check for permission to share form if ($form->getOwnerId() !== $this->currentUser->getUID()) { $this->logger->debug('This form is not owned by the current user'); - throw new OCSForbiddenException(); + throw new OCSForbiddenException('This form is not owned by the current user'); } if (!$this->validatePermissions($permissions, $shareType)) { @@ -157,11 +178,11 @@ public function newShare(int $formId, int $shareType, string $shareWith = '', ar // Check if hash already exists. (Unfortunately not possible here by unique index on db.) try { // Try loading a share to the hash. - $nonex = $this->shareMapper->findPublicShareByHash($shareWith); + $this->shareMapper->findPublicShareByHash($shareWith); // If we come here, a share has been found --> The share hash already exists, thus aborting. - $this->logger->debug('Share Hash already exists.'); - throw new OCSException('Share Hash exists. Please retry.'); + $this->logger->debug('Share hash already exists.'); + throw new OCSBadRequestException('Share hash exists, please retry.'); } catch (DoesNotExistException $e) { // Just continue, this is what we expect to happen (share hash not existing yet). } @@ -170,7 +191,7 @@ public function newShare(int $formId, int $shareType, string $shareWith = '', ar case IShare::TYPE_CIRCLE: if (!$this->circlesService->isCirclesEnabled()) { $this->logger->debug('Teams app is disabled, sharing to teams not possible.'); - throw new OCSException('Teams app is disabled.'); + throw new OCSBadRequestException('Teams app is disabled.'); } $circle = $this->circlesService->getCircle($shareWith); if (is_null($circle)) { @@ -182,7 +203,7 @@ public function newShare(int $formId, int $shareType, string $shareWith = '', ar default: // This passed the check for used shareTypes, but has not been found here. $this->logger->warning('Unknown, but used shareType: {shareType}. Please file an issue on GitHub.', [ 'shareType' => $shareType ]); - throw new OCSException('Unknown shareType.'); + throw new OCSBadRequestException('Unknown shareType.'); } $share = new Share(); @@ -202,7 +223,7 @@ public function newShare(int $formId, int $shareType, string $shareWith = '', ar $shareData = $share->read(); $shareData['displayName'] = $this->formsService->getShareDisplayName($shareData); - return new DataResponse($shareData); + return new DataResponse($shareData, Http::STATUS_CREATED); } /** @@ -210,14 +231,20 @@ public function newShare(int $formId, int $shareType, string $shareWith = '', ar * * @param int $formId of the form * @param int $shareId of the share to update - * @param array $keyValuePairs Array of key=>value pairs to update. - * @return DataResponse - * @throws OCSBadRequestException - * @throws OCSForbiddenException + * @param array $keyValuePairs Array of key=>value pairs to update. + * @return DataResponse + * @throws OCSBadRequestException Share doesn't belong to given Form + * @throws OCSBadRequestException Invalid permission given + * @throws OCSForbiddenException This form is not owned by the current user + * @throws OCSForbiddenException Empty keyValuePairs, will not update + * @throws OCSForbiddenException Not allowed to update other properties than permissions + * @throws OCSNotFoundException Could not find share + * + * 200: the id of the updated share */ #[CORS()] #[NoAdminRequired()] - #[ApiRoute(verb: 'PATCH', url: Constants::API_BASE . 'forms/{formId}/shares/{shareId}', requirements: Constants::API_V3_REQUIREMENTS)] + #[ApiRoute(verb: 'PATCH', url: '/api/v3/forms/{formId}/shares/{shareId}')] public function updateShare(int $formId, int $shareId, array $keyValuePairs): DataResponse { $this->logger->debug('Updating share: {shareId} of form {formId}, permissions: {permissions}', [ 'formId' => $formId, @@ -230,7 +257,7 @@ public function updateShare(int $formId, int $shareId, array $keyValuePairs): Da $form = $this->formMapper->findById($formId); } catch (IMapperException $e) { $this->logger->debug('Could not find share', ['exception' => $e]); - throw new OCSBadRequestException('Could not find share'); + throw new OCSNotFoundException('Could not find share'); } if ($formId !== $formShare->getFormId()) { @@ -240,19 +267,19 @@ public function updateShare(int $formId, int $shareId, array $keyValuePairs): Da if ($form->getOwnerId() !== $this->currentUser->getUID()) { $this->logger->debug('This form is not owned by the current user'); - throw new OCSForbiddenException(); + throw new OCSForbiddenException('This form is not owned by the current user'); } // Don't allow empty array if (sizeof($keyValuePairs) === 0) { $this->logger->info('Empty keyValuePairs, will not update.'); - throw new OCSForbiddenException(); + throw new OCSForbiddenException('Empty keyValuePairs, will not update'); } //Don't allow to change other properties than permissions - if (count($keyValuePairs) > 1 || !key_exists('permissions', $keyValuePairs)) { + if (count($keyValuePairs) > 1 || !array_key_exists('permissions', $keyValuePairs)) { $this->logger->debug('Not allowed to update other properties than permissions'); - throw new OCSForbiddenException(); + throw new OCSForbiddenException('Not allowed to update other properties than permissions'); } if (!$this->validatePermissions($keyValuePairs['permissions'], $formShare->getShareType())) { @@ -302,13 +329,16 @@ public function updateShare(int $formId, int $shareId, array $keyValuePairs): Da * * @param int $formId of the form * @param int $shareId of the share to delete - * @return DataResponse - * @throws OCSBadRequestException - * @throws OCSForbiddenException + * @return DataResponse + * @throws OCSBadRequestException Share doesn't belong to given Form + * @throws OCSForbiddenException This form is not owned by the current user + * @throws OCSNotFoundException Could not find share + * + * 200: the id of the deleted share */ #[CORS()] #[NoAdminRequired()] - #[ApiRoute(verb: 'DELETE', url: Constants::API_BASE . 'forms/{formId}/shares/{shareId}', requirements: Constants::API_V3_REQUIREMENTS)] + #[ApiRoute(verb: 'DELETE', url: '/api/v3/forms/{formId}/shares/{shareId}')] public function deleteShare(int $formId, int $shareId): DataResponse { $this->logger->debug('Deleting share: {shareId} of form {formId}', [ 'formId' => $formId, @@ -320,7 +350,7 @@ public function deleteShare(int $formId, int $shareId): DataResponse { $form = $this->formMapper->findById($formId); } catch (IMapperException $e) { $this->logger->debug('Could not find share', ['exception' => $e]); - throw new OCSBadRequestException('Could not find share'); + throw new OCSNotFoundException('Could not find share'); } if ($formId !== $share->getFormId()) { @@ -330,7 +360,7 @@ public function deleteShare(int $formId, int $shareId): DataResponse { if ($form->getOwnerId() !== $this->currentUser->getUID()) { $this->logger->debug('This form is not owned by the current user'); - throw new OCSForbiddenException(); + throw new OCSForbiddenException('This form is not owned by the current user'); } $this->shareMapper->delete($share); diff --git a/lib/Db/Answer.php b/lib/Db/Answer.php index 2b6097cd4..a62669f9b 100644 --- a/lib/Db/Answer.php +++ b/lib/Db/Answer.php @@ -28,9 +28,11 @@ namespace OCA\Forms\Db; +use OCA\Forms\ResponseDefinitions; use OCP\AppFramework\Db\Entity; /** + * @psalm-import-type FormsAnswer from ResponseDefinitions * @method integer getSubmissionId() * @method void setSubmissionId(integer $value) * @method integer getQuestionId() @@ -55,6 +57,9 @@ public function __construct() { $this->addType('fileId', 'integer'); } + /** + * @return FormsAnswer + */ public function read(): array { return [ 'id' => $this->getId(), diff --git a/lib/Db/Form.php b/lib/Db/Form.php index d1bd85c72..f1e9e410d 100644 --- a/lib/Db/Form.php +++ b/lib/Db/Form.php @@ -29,9 +29,11 @@ namespace OCA\Forms\Db; use OCA\Forms\Constants; +use OCA\Forms\ResponseDefinitions; use OCP\AppFramework\Db\Entity; /** + * @psalm-import-type FormsAccess from ResponseDefinitions * @method string getHash() * @method void setHash(string $value) * @method string getTitle() @@ -44,8 +46,6 @@ * @method void setFileId(int|null $value) * @method string|null getFileFormat() * @method void setFileFormat(string|null $value) - * @method array getAccess() - * @method void setAccess(array $value) * @method int getCreated() * @method void setCreated(int $value) * @method int getExpires() @@ -58,10 +58,12 @@ * @method void setShowExpiration(bool $value) * @method int getLastUpdated() * @method void setLastUpdated(int $value) - * @method ?string getSubmissionMessage() - * @method void setSubmissionMessage(?string $value) + * @method string|null getSubmissionMessage() + * @method void setSubmissionMessage(string|null $value) * @method int getState() - * @method void setState(?int $value) + * @psalm-method 0|1|2 getState() + * @method void setState(int|null $value) + * @psalm-method void setState(0|1|2|null $value) */ class Form extends Entity { protected $hash; @@ -94,6 +96,10 @@ public function __construct() { } // JSON-Decoding of access-column. + + /** + * @return FormsAccess + */ public function getAccess(): array { $accessEnum = $this->getAccessEnum(); $access = []; @@ -117,6 +123,10 @@ public function getAccess(): array { } // JSON-Encoding of access-column. + + /** + * @param FormsAccess $access + */ public function setAccess(array $access) { // No further permissions -> 0 // Permit all users, but don't show in navigation -> 1 @@ -131,11 +141,30 @@ public function setAccess(array $access) { // only permit all but not shown to all $value = Constants::FORM_ACCESS_PERMITALLUSERS; } - + $this->setAccessEnum($value); } - // Read full form + /** + * @return array{ + * id: int, + * hash: string, + * title: string, + * description: string, + * ownerId: string, + * fileId: ?int, + * fileFormat: ?string, + * created: int, + * access: FormsAccess, + * expires: int, + * isAnonymous: bool, + * submitMultiple: bool, + * showExpiration: bool, + * lastUpdated: int, + * submissionMessage: ?string, + * state: 0|1|2, + * } + */ public function read() { return [ 'id' => $this->getId(), diff --git a/lib/Db/Option.php b/lib/Db/Option.php index 01aaac6e0..6ff888cdd 100644 --- a/lib/Db/Option.php +++ b/lib/Db/Option.php @@ -26,9 +26,11 @@ namespace OCA\Forms\Db; +use OCA\Forms\ResponseDefinitions; use OCP\AppFramework\Db\Entity; /** + * @psalm-import-type FormsOption from ResponseDefinitions * @method int|float getQuestionId() * @method void setQuestionId(int|float $value) * @method string getText() @@ -55,6 +57,9 @@ public function __construct() { $this->addType('text', 'string'); } + /** + * @return FormsOption + */ public function read(): array { return [ 'id' => $this->getId(), diff --git a/lib/Db/Question.php b/lib/Db/Question.php index f71fe05fc..4ad4b8a0a 100644 --- a/lib/Db/Question.php +++ b/lib/Db/Question.php @@ -31,15 +31,21 @@ namespace OCA\Forms\Db; +use OCA\Forms\ResponseDefinitions; use OCP\AppFramework\Db\Entity; /** + * @psalm-import-type FormsQuestionExtraSettings from ResponseDefinitions + * @psalm-import-type FormsQuestionType from ResponseDefinitions * @method int getFormId() * @method void setFormId(integer $value) * @method int getOrder() * @method void setOrder(integer $value) + * @psalm-method FormsQuestionType getType() * @method string getType() + * @psalm-method 'date'|'datetime'|'dropdown'|'file'|'long'|'multiple'|'multiple_unique'|'short'|'time' getType() * @method void setType(string $value) + * @psalm-method void setType('date'|'datetime'|'dropdown'|'file'|'long'|'multiple'|'multiple_unique'|'short'|'time' $value) * @method bool getIsRequired() * @method void setIsRequired(bool $value) * @method string getText() @@ -69,10 +75,16 @@ public function __construct() { $this->addType('name', 'string'); } + /** + * @return FormsQuestionExtraSettings + */ public function getExtraSettings(): array { - return json_decode($this->getExtraSettingsJson() ?: '{}', true); // assoc=true, => Convert to associative Array + return json_decode($this->getExtraSettingsJson() ?: '{}', true, 512, JSON_THROW_ON_ERROR); } + /** + * @param FormsQuestionExtraSettings $extraSettings + */ public function setExtraSettings(array $extraSettings) { // Remove extraSettings that are not set foreach ($extraSettings as $key => $value) { @@ -81,9 +93,22 @@ public function setExtraSettings(array $extraSettings) { } } - $this->setExtraSettingsJson(json_encode($extraSettings, JSON_FORCE_OBJECT)); + $this->setExtraSettingsJson(json_encode($extraSettings, JSON_THROW_ON_ERROR | JSON_FORCE_OBJECT)); } + /** + * @return array{ + * id: int, + * formId: int, + * order: int, + * type: FormsQuestionType, + * isRequired: bool, + * text: string, + * name: string, + * description: string, + * extraSettings: FormsQuestionExtraSettings, + * } + */ public function read(): array { return [ 'id' => $this->getId(), diff --git a/lib/Db/Share.php b/lib/Db/Share.php index 56c171a49..d103c734a 100644 --- a/lib/Db/Share.php +++ b/lib/Db/Share.php @@ -27,9 +27,11 @@ namespace OCA\Forms\Db; use OCA\Forms\Constants; +use OCA\Forms\ResponseDefinitions; use OCP\AppFramework\Db\Entity; /** + * @psalm-import-type FormsPermission from ResponseDefinitions * @method int getFormId() * @method void setFormId(integer $value) * @method int getShareType() @@ -56,21 +58,33 @@ public function __construct() { $this->addType('shareWith', 'string'); } + /** + * @return list + */ public function getPermissions(): array { // Fallback to submit permission return json_decode($this->getPermissionsJson() ?: 'null') ?? [ Constants::PERMISSION_SUBMIT ]; } /** - * @param array $permissions + * @param list $permissions */ public function setPermissions(array $permissions) { $this->setPermissionsJson( // Make sure to only encode array values as the indices might be non consecutively so it would be encoded as a json object - json_encode(array_values($permissions)) + json_encode($permissions) ); } + /** + * @return array{ + * id: int, + * formId: int, + * shareType: int, + * shareWith: string, + * permissions: list, + * } + */ public function read(): array { return [ 'id' => $this->getId(), diff --git a/lib/Db/Submission.php b/lib/Db/Submission.php index 4fc1ece7e..4dfc4b88e 100644 --- a/lib/Db/Submission.php +++ b/lib/Db/Submission.php @@ -51,6 +51,14 @@ public function __construct() { $this->addType('timestamp', 'integer'); } + /** + * @return array{ + * id: int, + * formId: int, + * userId: string, + * timestamp: int, + * } + */ public function read(): array { return [ 'id' => $this->getId(), diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php new file mode 100644 index 000000000..79a4946aa --- /dev/null +++ b/lib/ResponseDefinitions.php @@ -0,0 +1,146 @@ + + * + * @author Christian Hartmann + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Forms; + +/** + * @psalm-type FormsOption = array{ + * id: int, + * questionId: int|float, + * text: string, + * order: ?int + * } + * + * @psalm-type FormsOrder = array{ + * order: int + * } + * + * @psalm-type FormsQuestionExtraSettings = array{ + * allowOtherAnswer?: ?bool, + * allowedFileExtensions?: ?list, + * allowedFileTypes?: ?list, + * maxAllowedFilesCount?: ?int, + * maxFileSize?: ?int, + * optionsLimitMax?: ?int, + * optionsLimitMin?: ?int, + * shuffleOptions?: ?bool, + * validationRegex?: ?string, + * validationType?: ?string + * } + * + * @psalm-type FormsQuestionType = "dropdown"|"multiple"|"multiple_unique"|"date"|"time"|"short"|"long"|"file"|"datetime" + * + * @psalm-type FormsQuestion = array{ + * id: int, + * formId: int, + * order: int, + * type: FormsQuestionType, + * isRequired: bool, + * text: string, + * name: string, + * description: string, + * extraSettings: FormsQuestionExtraSettings|\stdClass, + * options: list, + * accept: list, + * } + * + * @psalm-type FormsAnswer = array{ + * id: int, + * submissionId: int, + * fileId: ?int, + * questionId: int, + * text: string + * } + * + * @psalm-type FormsSubmission = array{ + * id: int, + * formId: int, + * userId: string, + * timestamp: int, + * answers: list, + * userDisplayName: string + * } + * + * @psalm-type FormsSubmissions = array{ + * submissions: list, + * questions: list + * } + * + * @psalm-type FormsAccess = array{ + * permitAllUsers?: bool, + * showToAllUsers?: bool + * } + * + * @psalm-type FormsPermission = "edit"|"results"|"results_delete"|"submit"|"embed" + * + * @psalm-type FormsShare = array{ + * id: int, + * formId: int, + * shareType: int, + * shareWith: string, + * permissions: list, + * displayName: string + * } + * + * @psalm-type FormsPartialForm = array{ + * id: int, + * hash: string, + * title: string, + * expires: int, + * permissions: list, + * partial: true, + * state: int + * } + * + * @psalm-type FormsForm = array{ + * id: int, + * hash: string, + * title: string, + * description: string, + * ownerId: string, + * created: int, + * access: FormsAccess, + * expires: int, + * fileFormat: ?string, + * fileId: ?int, + * filePath?: ?string, + * isAnonymous: bool, + * lastUpdated: int, + * submitMultiple: bool, + * showExpiration: bool, + * canSubmit: bool, + * permissions: list, + * questions: list, + * state: 0|1|2, + * shares: list, + * submissionCount?: int, + * submissionMessage: ?string, + * } + * + * @psalm-type FormsUploadedFile = array{ + * uploadedFileId: int, + * fileName: string + * } + */ +class ResponseDefinitions { +} diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index 540d545da..d45e5e9ad 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -36,6 +36,7 @@ use OCA\Forms\Db\Submission; use OCA\Forms\Db\SubmissionMapper; use OCA\Forms\Events\FormSubmittedEvent; +use OCA\Forms\ResponseDefinitions; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\IMapperException; use OCP\EventDispatcher\IEventDispatcher; @@ -53,6 +54,11 @@ /** * Trait for getting forms information in a service + * @psalm-import-type FormsQuestion from ResponseDefinitions + * @psalm-import-type FormsOption from ResponseDefinitions + * @psalm-import-type FormsForm from ResponseDefinitions + * @psalm-import-type FormsPermission from ResponseDefinitions + * @psalm-import-type FormsShare from ResponseDefinitions */ class FormsService { private ?IUser $currentUser; @@ -92,7 +98,7 @@ public function generateFormHash(): string { * Load options corresponding to question * * @param integer $questionId - * @return array + * @return list */ private function getOptions(int $questionId): array { $optionList = []; @@ -112,7 +118,7 @@ private function getOptions(int $questionId): array { * Load questions corresponding to form * * @param integer $formId - * @return array + * @return list */ public function getQuestions(int $formId): array { $questionList = []; @@ -137,6 +143,10 @@ public function getQuestions(int $formId): array { } } + if (empty($question['extraSettings'])) { + $question['extraSettings'] = new \stdClass(); + } + $questionList[] = $question; } } catch (DoesNotExistException $e) { @@ -150,10 +160,9 @@ public function getQuestions(int $formId): array { * Load specific question * * @param integer $questionId id of the question - * @return array + * @return ?FormsQuestion */ - public function getQuestion(int $questionId): array { - $question = []; + public function getQuestion(int $questionId): ?array { try { $questionEntity = $this->questionMapper->findById($questionId); $question = $questionEntity->read(); @@ -173,10 +182,12 @@ public function getQuestion(int $questionId): array { } } } - } catch (DoesNotExistException $e) { - //handle silently - } finally { + if (empty($question['extraSettings'])) { + $question['extraSettings'] = new \stdClass(); + } return $question; + } catch (DoesNotExistException $e) { + return null; } } @@ -184,7 +195,7 @@ public function getQuestion(int $questionId): array { * Load shares corresponding to form * * @param integer $formId - * @return array + * @return list */ public function getShares(int $formId): array { $shareList = []; @@ -203,7 +214,7 @@ public function getShares(int $formId): array { * Get a form data * * @param Form $form - * @return array + * @return FormsForm * @throws IMapperException */ public function getForm(Form $form): array { @@ -285,7 +296,7 @@ public function getPublicForm(Form $form): array { * Get current users permissions on a form * * @param Form $form - * @return array + * @return list */ public function getPermissions(Form $form): array { if (!$this->currentUser) { diff --git a/lib/Service/SubmissionService.php b/lib/Service/SubmissionService.php index 42c3bb1ce..2d2f70b85 100644 --- a/lib/Service/SubmissionService.php +++ b/lib/Service/SubmissionService.php @@ -36,6 +36,7 @@ use OCA\Forms\Db\QuestionMapper; use OCA\Forms\Db\SubmissionMapper; use OCA\Forms\Db\UploadedFileMapper; +use OCA\Forms\ResponseDefinitions; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\OCS\OCSException; use OCP\Files\File; @@ -57,9 +58,13 @@ use Psr\Log\LoggerInterface; +/** + * @psalm-import-type FormsSubmission from ResponseDefinitions + * @psalm-import-type FormsAnswer from ResponseDefinitions + */ class SubmissionService { private ?IUser $currentUser; - + public function __construct( private QuestionMapper $questionMapper, private SubmissionMapper $submissionMapper, @@ -83,7 +88,7 @@ public function __construct( * Get all the answers of a given submission * * @param int $submissionId the submission id - * @return array + * @return list */ private function getAnswers(int $submissionId): array { $answerList = []; @@ -103,7 +108,13 @@ private function getAnswers(int $submissionId): array { * Get all submissions of a form * * @param int $formId the form id - * @return array + * @return list, + * }> */ public function getSubmissions(int $formId): array { $submissionList = []; diff --git a/openapi.json b/openapi.json new file mode 100644 index 000000000..d9b3c4745 --- /dev/null +++ b/openapi.json @@ -0,0 +1,4420 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "forms", + "version": "0.0.1", + "description": "📝 Simple surveys and questionnaires, self-hosted", + "license": { + "name": "agpl" + } + }, + "components": { + "securitySchemes": { + "basic_auth": { + "type": "http", + "scheme": "basic" + }, + "bearer_auth": { + "type": "http", + "scheme": "bearer" + } + }, + "schemas": { + "Access": { + "type": "object", + "properties": { + "permitAllUsers": { + "type": "boolean" + }, + "showToAllUsers": { + "type": "boolean" + } + } + }, + "Answer": { + "type": "object", + "required": [ + "id", + "submissionId", + "fileId", + "questionId", + "text" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "submissionId": { + "type": "integer", + "format": "int64" + }, + "fileId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "questionId": { + "type": "integer", + "format": "int64" + }, + "text": { + "type": "string" + } + } + }, + "Capabilities": { + "type": "object", + "required": [ + "forms" + ], + "properties": { + "forms": { + "type": "object", + "required": [ + "version", + "apiVersions" + ], + "properties": { + "version": { + "type": "string" + }, + "apiVersions": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "Form": { + "type": "object", + "required": [ + "id", + "hash", + "title", + "description", + "ownerId", + "created", + "access", + "expires", + "fileFormat", + "fileId", + "isAnonymous", + "lastUpdated", + "submitMultiple", + "showExpiration", + "canSubmit", + "permissions", + "questions", + "state", + "shares", + "submissionMessage" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "hash": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "ownerId": { + "type": "string" + }, + "created": { + "type": "integer", + "format": "int64" + }, + "access": { + "$ref": "#/components/schemas/Access" + }, + "expires": { + "type": "integer", + "format": "int64" + }, + "fileFormat": { + "type": "string", + "nullable": true + }, + "fileId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "filePath": { + "type": "string", + "nullable": true + }, + "isAnonymous": { + "type": "boolean" + }, + "lastUpdated": { + "type": "integer", + "format": "int64" + }, + "submitMultiple": { + "type": "boolean" + }, + "showExpiration": { + "type": "boolean" + }, + "canSubmit": { + "type": "boolean" + }, + "permissions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Permission" + } + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Question" + } + }, + "state": { + "type": "integer", + "format": "int64", + "enum": [ + 0, + 1, + 2 + ] + }, + "shares": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Share" + } + }, + "submissionCount": { + "type": "integer", + "format": "int64" + }, + "submissionMessage": { + "type": "string", + "nullable": true + } + } + }, + "OCSMeta": { + "type": "object", + "required": [ + "status", + "statuscode" + ], + "properties": { + "status": { + "type": "string" + }, + "statuscode": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "totalitems": { + "type": "string" + }, + "itemsperpage": { + "type": "string" + } + } + }, + "Option": { + "type": "object", + "required": [ + "id", + "questionId", + "text", + "order" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "questionId": { + "anyOf": [ + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + } + ] + }, + "text": { + "type": "string" + }, + "order": { + "type": "integer", + "format": "int64", + "nullable": true + } + } + }, + "Order": { + "type": "object", + "required": [ + "order" + ], + "properties": { + "order": { + "type": "integer", + "format": "int64" + } + } + }, + "PartialForm": { + "type": "object", + "required": [ + "id", + "hash", + "title", + "expires", + "permissions", + "partial", + "state" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "hash": { + "type": "string" + }, + "title": { + "type": "string" + }, + "expires": { + "type": "integer", + "format": "int64" + }, + "permissions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Permission" + } + }, + "partial": { + "type": "boolean", + "enum": [ + true + ] + }, + "state": { + "type": "integer", + "format": "int64" + } + } + }, + "Permission": { + "type": "string", + "enum": [ + "edit", + "results", + "results_delete", + "submit", + "embed" + ] + }, + "Question": { + "type": "object", + "required": [ + "id", + "formId", + "order", + "type", + "isRequired", + "text", + "name", + "description", + "extraSettings", + "options", + "accept" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "formId": { + "type": "integer", + "format": "int64" + }, + "order": { + "type": "integer", + "format": "int64" + }, + "type": { + "$ref": "#/components/schemas/QuestionType" + }, + "isRequired": { + "type": "boolean" + }, + "text": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "extraSettings": { + "anyOf": [ + { + "$ref": "#/components/schemas/QuestionExtraSettings" + }, + { + "type": "object", + "additionalProperties": true + } + ] + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Option" + } + }, + "accept": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "QuestionExtraSettings": { + "type": "object", + "properties": { + "allowOtherAnswer": { + "type": "boolean", + "nullable": true + }, + "allowedFileExtensions": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + } + }, + "allowedFileTypes": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + } + }, + "maxAllowedFilesCount": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "maxFileSize": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "optionsLimitMax": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "optionsLimitMin": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "shuffleOptions": { + "type": "boolean", + "nullable": true + }, + "validationRegex": { + "type": "string", + "nullable": true + }, + "validationType": { + "type": "string", + "nullable": true + } + } + }, + "QuestionType": { + "type": "string", + "enum": [ + "dropdown", + "multiple", + "multiple_unique", + "date", + "time", + "short", + "long", + "file", + "datetime" + ] + }, + "Share": { + "type": "object", + "required": [ + "id", + "formId", + "shareType", + "shareWith", + "permissions", + "displayName" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "formId": { + "type": "integer", + "format": "int64" + }, + "shareType": { + "type": "integer", + "format": "int64" + }, + "shareWith": { + "type": "string" + }, + "permissions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Permission" + } + }, + "displayName": { + "type": "string" + } + } + }, + "Submission": { + "type": "object", + "required": [ + "id", + "formId", + "userId", + "timestamp", + "answers", + "userDisplayName" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "formId": { + "type": "integer", + "format": "int64" + }, + "userId": { + "type": "string" + }, + "timestamp": { + "type": "integer", + "format": "int64" + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Answer" + } + }, + "userDisplayName": { + "type": "string" + } + } + }, + "Submissions": { + "type": "object", + "required": [ + "submissions", + "questions" + ], + "properties": { + "submissions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Submission" + } + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Question" + } + } + } + }, + "UploadedFile": { + "type": "object", + "required": [ + "uploadedFileId", + "fileName" + ], + "properties": { + "uploadedFileId": { + "type": "integer", + "format": "int64" + }, + "fileName": { + "type": "string" + } + } + } + } + }, + "paths": { + "/ocs/v2.php/apps/forms/api/v3/forms": { + "get": { + "operationId": "api-get-forms", + "summary": "Get all forms available to the user (owned/shared)", + "tags": [ + "api" + ], + "security": [ + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "type", + "in": "query", + "description": "The type of forms to retrieve. Defaults to `owned`. Possible values: - `owned`: Forms owned by the user. - `shared`: Forms shared with the user.", + "schema": { + "type": "string", + "default": "owned" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Array containing the partial owned or shared forms", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PartialForm" + } + } + } + } + } + } + } + } + }, + "400": { + "description": "wrong form type supplied", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "post": { + "operationId": "api-new-form", + "summary": "Create a new form and return the form Return a copy of the form if the parameter $fromId is set", + "tags": [ + "api" + ], + "security": [ + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "fromId": { + "type": "integer", + "format": "int64", + "nullable": true, + "description": "(optional) Id of the form that should be cloned" + } + } + } + } + } + }, + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "201": { + "description": "the created form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Form" + } + } + } + } + } + } + } + }, + "403": { + "description": "The user is not allowed to create forms", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/forms/api/v3/forms/{formId}": { + "get": { + "operationId": "api-get-form", + "summary": "Read all information to edit a Form (form, questions, options, except submissions/answers)", + "tags": [ + "api" + ], + "security": [ + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "formId", + "in": "path", + "description": "Id of the form", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "the requested form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Form" + } + } + } + } + } + } + } + }, + "400": { + "description": "Could not find form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "User has no permissions to get this form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "patch": { + "operationId": "api-update-form", + "summary": "Writes the given key-value pairs into Database", + "tags": [ + "api" + ], + "security": [ + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "keyValuePairs" + ], + "properties": { + "keyValuePairs": { + "type": "object", + "description": "Array of key=>value pairs to update.", + "additionalProperties": { + "type": "object" + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "formId", + "in": "path", + "description": "FormId of form to update", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "the id of the updated form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "oneOf": [ + { + "type": "integer", + "format": "int64" + }, + { + "type": "string" + } + ] + } + } + } + } + } + } + } + }, + "400": { + "description": "Could not find new form owner", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "User is not allowed to modify the form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Form not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "api-delete-form", + "summary": "Delete a form", + "tags": [ + "api" + ], + "security": [ + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "formId", + "in": "path", + "description": "the form id", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "the id of the deleted form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "integer", + "format": "int64" + } + } + } + } + } + } + } + }, + "403": { + "description": "User is not allowed to delete the form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Form not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/forms/api/v3/forms/{formId}/questions": { + "get": { + "operationId": "api-get-questions", + "summary": "Read all questions (including options)", + "tags": [ + "api" + ], + "security": [ + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "formId", + "in": "path", + "description": "the form id", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "the questions of the given form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Question" + } + } + } + } + } + } + } + } + }, + "403": { + "description": "User has no permissions to get this form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Could not find form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "post": { + "operationId": "api-new-question", + "summary": "Add a new question", + "tags": [ + "api" + ], + "security": [ + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/QuestionType", + "description": "the new question type" + }, + "text": { + "type": "string", + "default": "", + "description": "the new question title" + }, + "fromId": { + "type": "integer", + "format": "int64", + "nullable": true, + "description": "(optional) id of the question that should be cloned" + } + } + } + } + } + }, + "parameters": [ + { + "name": "formId", + "in": "path", + "description": "the form id", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "201": { + "description": "the created question", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Question" + } + } + } + } + } + } + } + }, + "400": { + "description": "Datetime question type no longer supported", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "This form is archived and can not be modified", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Could not find question", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "patch": { + "operationId": "api-reorder-questions", + "summary": "Updates the Order of all Questions of a Form", + "tags": [ + "api" + ], + "security": [ + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "newOrder" + ], + "properties": { + "newOrder": { + "type": "array", + "description": "Array of Question-Ids in new order.", + "items": { + "type": "integer", + "format": "int64" + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "formId", + "in": "path", + "description": "Id of the form to reorder", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "the question ids of the given form in the new order", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Order" + } + } + } + } + } + } + } + } + }, + "400": { + "description": "One question has already been marked as deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "User has no permissions to get this form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Could not find question", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/forms/api/v3/forms/{formId}/questions/{questionId}": { + "get": { + "operationId": "api-get-question", + "summary": "Read a specific question (including options)", + "tags": [ + "api" + ], + "security": [ + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "formId", + "in": "path", + "description": "FormId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "questionId", + "in": "path", + "description": "QuestionId", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "the requested question", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Question" + } + } + } + } + } + } + } + }, + "400": { + "description": "Question doesn\\'t belong to given Form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "User has no permissions to get this form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Could not find form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "patch": { + "operationId": "api-update-question", + "summary": "Writes the given key-value pairs into Database Key `order` should only be changed by reorderQuestions() and is not allowed here", + "tags": [ + "api" + ], + "security": [ + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "keyValuePairs" + ], + "properties": { + "keyValuePairs": { + "type": "object", + "description": "Array of key=>value pairs to update.", + "additionalProperties": { + "type": "object" + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "formId", + "in": "path", + "description": "the form id", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "questionId", + "in": "path", + "description": "id of question to update", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "the id of the updated question", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "integer", + "format": "int64" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid extraSettings, will not update.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "User has no permissions to get this form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Could not find question", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "api-delete-question", + "summary": "Delete a question", + "tags": [ + "api" + ], + "security": [ + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "formId", + "in": "path", + "description": "the form id", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "questionId", + "in": "path", + "description": "the question id", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "the id of the deleted question", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "integer", + "format": "int64" + } + } + } + } + } + } + } + }, + "400": { + "description": "Question doesn\\'t belong to given Form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "User has no permissions to get this form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Could not find question", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/forms/api/v3/forms/{formId}/questions/{questionId}/options": { + "post": { + "operationId": "api-new-option", + "summary": "Add a new option to a question", + "tags": [ + "api" + ], + "security": [ + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "optionTexts" + ], + "properties": { + "optionTexts": { + "type": "array", + "description": "the new option text", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "formId", + "in": "path", + "description": "id of the form", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "questionId", + "in": "path", + "description": "id of the question", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "201": { + "description": "the created option", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Option" + } + } + } + } + } + } + } + } + }, + "400": { + "description": "This question is not part ot the given form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "Current user has no permission to edit", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Could not find question", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "patch": { + "operationId": "api-reorder-options", + "summary": "Reorder options for a given question", + "tags": [ + "api" + ], + "security": [ + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "newOrder" + ], + "properties": { + "newOrder": { + "type": "array", + "description": "Array of option ids in new order.", + "items": { + "type": "integer", + "format": "int64" + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "formId", + "in": "path", + "description": "id of form", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "questionId", + "in": "path", + "description": "id of question", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "the options of the question in the new order", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Order" + } + } + } + } + } + } + } + } + }, + "400": { + "description": "This option is not part of the given question", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "Current user has no permission to edit", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Could not find question", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/forms/api/v3/forms/{formId}/questions/{questionId}/options/{optionId}": { + "patch": { + "operationId": "api-update-option", + "summary": "Writes the given key-value pairs into Database", + "tags": [ + "api" + ], + "security": [ + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "keyValuePairs" + ], + "properties": { + "keyValuePairs": { + "type": "object", + "description": "Array of key=>value pairs to update.", + "additionalProperties": { + "type": "object" + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "formId", + "in": "path", + "description": "id of form", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "questionId", + "in": "path", + "description": "id of question", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "optionId", + "in": "path", + "description": "id of option to update", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "the id of the updated option", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "integer", + "format": "int64" + } + } + } + } + } + } + } + }, + "400": { + "description": "The given option id doesn't match the question or form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "Not allowed to update id or questionId", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Could not find option or question", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "api-delete-option", + "summary": "Delete an option", + "tags": [ + "api" + ], + "security": [ + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "formId", + "in": "path", + "description": "id of form", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "questionId", + "in": "path", + "description": "id of question", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "optionId", + "in": "path", + "description": "id of option to update", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "the id of the deleted option", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "integer", + "format": "int64" + } + } + } + } + } + } + } + }, + "400": { + "description": "The given option id doesn't match the question or form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "Current user has no permission to edit", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Could not find question or option", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/forms/api/v3/forms/{formId}/submissions": { + "get": { + "operationId": "api-get-submissions", + "summary": "Get all the submissions of a given form", + "tags": [ + "api" + ], + "security": [ + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "formId", + "in": "path", + "description": "of the form", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "fileFormat", + "in": "query", + "description": "the file format that should be used for the download. Defaults to `null` Possible values: - `csv`: Comma-separated value - `ods`: OpenDocument Spreadsheet - `xlsx`: Excel Open XML Spreadsheet", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "the submissions of the form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Submissions" + } + } + } + } + } + }, + "text/csv": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "application/vnd.oasis.opendocument.spreadsheet": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Could not find form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "The current user has no permission to get the results for this form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "api-delete-all-submissions", + "summary": "Delete all submissions of a specified form", + "tags": [ + "api" + ], + "security": [ + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "formId", + "in": "path", + "description": "the form id", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "the form id of the deleted submission", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "integer", + "format": "int64" + } + } + } + } + } + } + } + }, + "404": { + "description": "Could not find form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "This form is not owned by the current user and user has no `results_delete` permission", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "post": { + "operationId": "api-new-submission", + "summary": "Process a new submission", + "tags": [ + "api" + ], + "security": [ + {}, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "answers" + ], + "properties": { + "answers": { + "type": "object", + "description": "[question_id => arrayOfString]", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "shareHash": { + "type": "string", + "default": "", + "description": "public share-hash -> Necessary to submit on public link-shares." + } + } + } + } + } + }, + "parameters": [ + { + "name": "formId", + "in": "path", + "description": "the form id", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "201": { + "description": "empty response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "400": { + "description": "At least one submitted answer is not valid", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "This form is not owned by the current user and user has no `results_delete` permission", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Could not find form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/forms/api/v3/forms/{formId}/submissions/{submissionId}": { + "delete": { + "operationId": "api-delete-submission", + "summary": "Delete a specific submission", + "tags": [ + "api" + ], + "security": [ + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "formId", + "in": "path", + "description": "the form id", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "submissionId", + "in": "path", + "description": "the submission id", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "the id of the deleted submission", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "integer", + "format": "int64" + } + } + } + } + } + } + } + }, + "400": { + "description": "Submission doesn't belong to given form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Could not find form or submission", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "This form is not owned by the current user and user has no `results_delete` permission", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/forms/api/v3/forms/{formId}/submissions/export": { + "post": { + "operationId": "api-export-submissions-to-cloud", + "summary": "Export Submissions to the Cloud", + "tags": [ + "api" + ], + "security": [ + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "path", + "fileFormat" + ], + "properties": { + "path": { + "type": "string", + "description": "The Cloud-Path to export to" + }, + "fileFormat": { + "type": "string", + "description": "File format used for export" + } + } + } + } + } + }, + "parameters": [ + { + "name": "formId", + "in": "path", + "description": "of the form", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "the file name used for storing the submissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "403": { + "description": "The current user has no permission to get the results for this form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Could not find form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/forms/api/v3/forms/{formId}/submissions/files/{questionId}": { + "post": { + "operationId": "api-upload-files", + "summary": "Uploads a temporary files to the server during form filling", + "tags": [ + "api" + ], + "security": [ + {}, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "shareHash": { + "type": "string", + "default": "", + "description": "hash of the form share" + } + } + } + } + } + }, + "parameters": [ + { + "name": "formId", + "in": "path", + "description": "id of the form", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "questionId", + "in": "path", + "description": "id of the question", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "the file id and name of the uploaded file", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UploadedFile" + } + } + } + } + } + } + } + } + }, + "400": { + "description": "File type is not allowed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "Already submitted", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Could not find question", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/forms/api/v3/forms/{formId}/shares": { + "post": { + "operationId": "share_api-new-share", + "summary": "Add a new share", + "tags": [ + "share_api" + ], + "security": [ + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "shareType" + ], + "properties": { + "shareType": { + "type": "integer", + "format": "int64", + "description": "Nextcloud-ShareType" + }, + "shareWith": { + "type": "string", + "default": "", + "description": "ID of user/group/... to share with. For Empty shareWith and shareType Link, this will be set as RandomID." + }, + "permissions": { + "type": "array", + "default": [], + "description": "the permissions granted on the share, defaults to `submit`\n Possible values:\n - `submit` user can submit\n - `results` user can see the results\n - `results_delete` user can see and delete results", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "formId", + "in": "path", + "description": "The form to share", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "201": { + "description": "the created share", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Share" + } + } + } + } + } + } + } + }, + "400": { + "description": "Teams app is disabled", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "This form is not owned by the current user", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Could not find form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/forms/api/v3/forms/{formId}/shares/{shareId}": { + "patch": { + "operationId": "share_api-update-share", + "summary": "Update permissions of a share", + "tags": [ + "share_api" + ], + "security": [ + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "keyValuePairs" + ], + "properties": { + "keyValuePairs": { + "type": "object", + "description": "Array of key=>value pairs to update.", + "additionalProperties": { + "type": "object" + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "formId", + "in": "path", + "description": "of the form", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "shareId", + "in": "path", + "description": "of the share to update", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "the id of the updated share", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "integer", + "format": "int64" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid permission given", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "Not allowed to update other properties than permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Could not find share", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "share_api-delete-share", + "summary": "Delete a share", + "tags": [ + "share_api" + ], + "security": [ + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "formId", + "in": "path", + "description": "of the form", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "shareId", + "in": "path", + "description": "of the share to delete", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "the id of the deleted share", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "integer", + "format": "int64" + } + } + } + } + } + } + } + }, + "400": { + "description": "Share doesn't belong to given Form", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "This form is not owned by the current user", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Could not find share", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + } + }, + "tags": [] +} diff --git a/psalm.xml b/psalm.xml index 62d66ea73..1f4f5af69 100644 --- a/psalm.xml +++ b/psalm.xml @@ -22,6 +22,10 @@ + + + + diff --git a/tests/Integration/Api/ApiV3Test.php b/tests/Integration/Api/ApiV3Test.php index a36079c74..110dcd14e 100644 --- a/tests/Integration/Api/ApiV3Test.php +++ b/tests/Integration/Api/ApiV3Test.php @@ -421,7 +421,7 @@ public function testGetNewForm(array $expected): void { $this->assertEqualsWithDelta(time(), $data['lastUpdated'], 10); unset($data['lastUpdated']); - $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals(201, $resp->getStatusCode()); $this->assertEquals($expected, $data); } @@ -623,7 +623,7 @@ public function testCloneForm(array $expected): void { $this->assertTrue(time() - $data['lastUpdated'] < 10); unset($data['lastUpdated']); - $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals(201, $resp->getStatusCode()); $this->assertEquals($expected, $data); } @@ -740,7 +740,7 @@ public function testCreateNewQuestion(array $expected): void { unset($data['order']); unset($data['id']); - $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals(201, $resp->getStatusCode()); $this->assertEquals($expected, $data); } @@ -860,7 +860,7 @@ public function testCloneQuestion() { $data = $this->OcsResponse2Data($resp); $this->testForms[0]['questions'][] = $data; - $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals(201, $resp->getStatusCode()); $this->assertNotEquals($data['id'], $this->testForms[0]['questions'][0]['id']); $copy = $this->testForms[0]['questions'][0]; @@ -903,7 +903,7 @@ public function testCreateNewOption(array $expected): void { unset($data['questionId']); unset($data['id']); - $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals(201, $resp->getStatusCode()); $this->assertEquals($expected, $data); } @@ -1001,7 +1001,7 @@ public function testAddShare(array $expected) { // Store for cleanup $this->testForms[0]['shares'][] = $data; - $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals(201, $resp->getStatusCode()); $this->assertEquals($this->testForms[0]['id'], $data['formId']); unset($data['formId']); unset($data['id']); @@ -1315,7 +1315,7 @@ public function testNewSubmission() { ]); $data = $this->OcsResponse2Data($resp); - $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals(201, $resp->getStatusCode()); // Check stored submissions $resp = $this->http->request('GET', "api/v3/forms/{$this->testForms[0]['id']}/submissions"); diff --git a/tests/Unit/Controller/ApiControllerTest.php b/tests/Unit/Controller/ApiControllerTest.php index fa27ba42d..a5a899944 100644 --- a/tests/Unit/Controller/ApiControllerTest.php +++ b/tests/Unit/Controller/ApiControllerTest.php @@ -63,6 +63,7 @@ function is_uploaded_file(string|bool|null $filename) { use OCA\Forms\Service\FormsService; use OCA\Forms\Service\SubmissionService; use OCA\Forms\Tests\Unit\MockedMapperException; +use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataDownloadResponse; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCS\OCSBadRequestException; @@ -406,30 +407,6 @@ public function dataTestCreateNewForm() { * @dataProvider dataTestCreateNewForm() */ public function testCreateNewForm($expectedForm) { - // Create a partial mock, as we only test newForm and not getForm - /** @var ApiController|MockObject */ - $apiController = $this->getMockBuilder(ApiController::class) - ->onlyMethods(['getForm']) - ->setConstructorArgs(['forms', - $this->request, - $this->createUserSession(), - $this->answerMapper, - $this->formMapper, - $this->optionMapper, - $this->questionMapper, - $this->shareMapper, - $this->submissionMapper, - $this->configService, - $this->formsService, - $this->submissionService, - $this->l10n, - $this->logger, - $this->userManager, - $this->storage, - $this->uploadedFileMapper, - $this->mimeTypeDetector, - ])->getMock(); - $this->configService->expects($this->once()) ->method('canCreateForms') ->willReturn(true); @@ -448,11 +425,7 @@ public function testCreateNewForm($expectedForm) { $form->setId(7); return $form; }); - $apiController->expects($this->once()) - ->method('getForm') - ->with(7) - ->willReturn(new DataResponse('succeeded')); - $this->assertEquals(new DataResponse('succeeded'), $apiController->newForm()); + $this->assertEquals(new DataResponse([], Http::STATUS_CREATED), $this->apiController->newForm()); } public function dataCloneForm_exceptions() { @@ -568,35 +541,7 @@ public function testCloneForm($old, $new) { ->with(7) ->willReturn([]); - /** @var ApiController|MockObject */ - $apiController = $this->getMockBuilder(ApiController::class) - ->onlyMethods(['getForm']) - ->setConstructorArgs(['forms', - $this->request, - $this->createUserSession(), - $this->answerMapper, - $this->formMapper, - $this->optionMapper, - $this->questionMapper, - $this->shareMapper, - $this->submissionMapper, - $this->configService, - $this->formsService, - $this->submissionService, - $this->l10n, - $this->logger, - $this->userManager, - $this->storage, - $this->uploadedFileMapper, - $this->mimeTypeDetector, - ]) - ->getMock(); - - $apiController->expects($this->once()) - ->method('getForm') - ->with(14) - ->willReturn(new DataResponse('success')); - $this->assertEquals(new DataResponse('success'), $apiController->newForm(7)); + $this->assertEquals(new DataResponse([], Http::STATUS_CREATED), $this->apiController->newForm(7)); } private function formAccess(bool $hasUserAccess = true, bool $hasFormExpired = false, bool $canSubmit = true) { @@ -826,7 +771,7 @@ public function testNewSubmission_formNotFound() { ->method('findById') ->with(1) ->willThrowException($exception); - $this->expectException(OCSBadRequestException::class); + $this->expectException(OCSNotFoundException::class); $this->apiController->newSubmission(1, [], ''); } @@ -900,7 +845,7 @@ public function testDeleteSubmissionNotFound() { ->with(42) ->willThrowException($exception); - $this->expectException(OCSBadRequestException::class); + $this->expectException(OCSNotFoundException::class); $this->apiController->deleteSubmission(1, 42); } diff --git a/tests/Unit/Controller/ShareApiControllerTest.php b/tests/Unit/Controller/ShareApiControllerTest.php index 588945238..688ae66f2 100644 --- a/tests/Unit/Controller/ShareApiControllerTest.php +++ b/tests/Unit/Controller/ShareApiControllerTest.php @@ -37,10 +37,11 @@ use OCA\Forms\Service\FormsService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\IMapperException; +use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCS\OCSBadRequestException; -use OCP\AppFramework\OCS\OCSException; use OCP\AppFramework\OCS\OCSForbiddenException; +use OCP\AppFramework\OCS\OCSNotFoundException; use OCP\Files\File; use OCP\Files\Folder; use OCP\Files\IRootFolder; @@ -243,7 +244,7 @@ public function testValidNewShare(int $shareType, string $shareWith, array $perm ->willReturn($shareWith . ' DisplayName'); // Share Form '5' to 'user1' of share-type 'user=0' - $expectedResponse = new DataResponse($expected); + $expectedResponse = new DataResponse($expected, Http::STATUS_CREATED); $this->assertEquals($expectedResponse, $this->shareApiController->newShare(5, $shareType, $shareWith, $permissions)); } @@ -326,7 +327,7 @@ public function testNewLinkShare(int $shareType, string $shareWith, array $permi if ($exception === null) { // Share the form. - $expectedResponse = new DataResponse($expected); + $expectedResponse = new DataResponse($expected, Http::STATUS_CREATED); $this->assertEquals($expectedResponse, $this->shareApiController->newShare(5, $shareType, $shareWith, $permissions)); } else { $this->expectException($exception); @@ -363,7 +364,7 @@ public function testNewLinkShare_ExistingHash() { $this->shareMapper->expects($this->never()) ->method('insert'); - $this->expectException(OCSException::class); + $this->expectException(OCSBadRequestException::class); $this->shareApiController->newShare(5, IShare::TYPE_LINK, ''); } @@ -382,7 +383,7 @@ public function testNewLinkShare_PublicLinkNotAllowed() { $this->shareMapper->expects($this->never()) ->method('insert'); - $this->expectException(OCSException::class); + $this->expectException(OCSForbiddenException::class); $this->shareApiController->newShare(5, IShare::TYPE_LINK, ''); } @@ -489,7 +490,7 @@ public function testCirclesShare_circlesAppDisabled() { ->willReturn(false); - $this->expectException(OCSException::class); + $this->expectException(OCSBadRequestException::class); // Share Form '5' to 'noCircle' $this->shareApiController->newShare(5, IShare::TYPE_CIRCLE, 'noCircle'); @@ -505,7 +506,7 @@ public function testShareUnknownForm() { ->will($this->throwException(new DoesNotExistException('Form not found'))); ; - $this->expectException(OCSBadRequestException::class); + $this->expectException(OCSNotFoundException::class); $this->shareApiController->newShare(5, 0, 'user1'); } @@ -564,7 +565,7 @@ public function testDeleteUnknownShare() { ->will($this->throwException(new DoesNotExistException('Share not found'))); ; - $this->expectException(OCSBadRequestException::class); + $this->expectException(OCSNotFoundException::class); $this->shareApiController->deleteShare(1, 8); } @@ -845,7 +846,7 @@ public function testUpdateShare_NotExistingShare() { $this->logger->expects($this->exactly(2)) ->method('debug'); - $this->expectException(OCSBadRequestException::class); + $this->expectException(OCSNotFoundException::class); $this->shareApiController->updateShare(1, 1337, [Constants::PERMISSION_SUBMIT]); } @@ -875,7 +876,7 @@ public function testUpdateShare_NotExistingForm() { $this->logger->expects($this->exactly(2)) ->method('debug'); - $this->expectException(OCSBadRequestException::class); + $this->expectException(OCSNotFoundException::class); $this->shareApiController->updateShare(7331, 1337, [Constants::PERMISSION_SUBMIT]); } } diff --git a/tests/Unit/Service/FormsServiceTest.php b/tests/Unit/Service/FormsServiceTest.php index 349331179..066921bba 100644 --- a/tests/Unit/Service/FormsServiceTest.php +++ b/tests/Unit/Service/FormsServiceTest.php @@ -246,7 +246,7 @@ public function dataGetForm() { 'order' => 2, 'type' => 'short', 'isRequired' => true, - 'extraSettings' => [], + 'extraSettings' => new \stdClass, 'text' => 'Question 2', 'description' => '', 'name' => 'city', diff --git a/vendor-bin/openapi-extractor/composer.json b/vendor-bin/openapi-extractor/composer.json new file mode 100644 index 000000000..71320cbc6 --- /dev/null +++ b/vendor-bin/openapi-extractor/composer.json @@ -0,0 +1,5 @@ +{ + "require-dev": { + "nextcloud/openapi-extractor": "^1.0" + } +} diff --git a/vendor-bin/openapi-extractor/composer.lock b/vendor-bin/openapi-extractor/composer.lock new file mode 100644 index 000000000..9e19a1f49 --- /dev/null +++ b/vendor-bin/openapi-extractor/composer.lock @@ -0,0 +1,240 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "6964fdd288b96adcca764f7b24d4d678", + "packages": [], + "packages-dev": [ + { + "name": "adhocore/cli", + "version": "v1.7.2", + "source": { + "type": "git", + "url": "https://github.com/adhocore/php-cli.git", + "reference": "57834cbaa4fb68cda849417ab86577fba2b15298" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/adhocore/php-cli/zipball/57834cbaa4fb68cda849417ab86577fba2b15298", + "reference": "57834cbaa4fb68cda849417ab86577fba2b15298", + "shasum": "" + }, + "require": { + "php": ">=8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Ahc\\Cli\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jitendra Adhikari", + "email": "jiten.adhikary@gmail.com" + } + ], + "description": "Command line interface library for PHP", + "keywords": [ + "argument-parser", + "argv-parser", + "cli", + "cli-action", + "cli-app", + "cli-color", + "cli-option", + "cli-writer", + "command", + "console", + "console-app", + "php-cli", + "php8", + "stream-input", + "stream-output" + ], + "support": { + "issues": "https://github.com/adhocore/php-cli/issues", + "source": "https://github.com/adhocore/php-cli/tree/v1.7.2" + }, + "funding": [ + { + "url": "https://paypal.me/ji10", + "type": "custom" + }, + { + "url": "https://github.com/adhocore", + "type": "github" + } + ], + "time": "2024-09-05T00:08:47+00:00" + }, + { + "name": "nextcloud/openapi-extractor", + "version": "v1.0.0", + "source": { + "type": "git", + "url": "https://github.com/nextcloud-releases/openapi-extractor.git", + "reference": "88e347097db28b6e3f8f3c221502b80a4f455b1f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nextcloud-releases/openapi-extractor/zipball/88e347097db28b6e3f8f3c221502b80a4f455b1f", + "reference": "88e347097db28b6e3f8f3c221502b80a4f455b1f", + "shasum": "" + }, + "require": { + "adhocore/cli": "^1.7", + "ext-simplexml": "*", + "nikic/php-parser": "^5.0", + "php": "^8.1", + "phpstan/phpdoc-parser": "^1.28" + }, + "require-dev": { + "nextcloud/coding-standard": "^1.2", + "nextcloud/ocp": "dev-master" + }, + "bin": [ + "generate-spec", + "merge-specs" + ], + "type": "library", + "autoload": { + "psr-4": { + "OpenAPIExtractor\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "AGPL-3.0-or-later" + ], + "description": "A tool for extracting OpenAPI specifications from Nextcloud source code", + "support": { + "issues": "https://github.com/nextcloud-releases/openapi-extractor/issues", + "source": "https://github.com/nextcloud-releases/openapi-extractor/tree/v1.0.0" + }, + "time": "2024-08-20T16:46:27+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.3.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "3abf7425cd284141dc5d8d14a9ee444de3345d1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3abf7425cd284141dc5d8d14a9ee444de3345d1a", + "reference": "3abf7425cd284141dc5d8d14a9ee444de3345d1a", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.3.0" + }, + "time": "2024-09-29T13:56:26+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "1.32.0", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "6ca22b154efdd9e3c68c56f5d94670920a1c19a4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/6ca22b154efdd9e3c68c56f5d94670920a1c19a4", + "reference": "6ca22b154efdd9e3c68c56f5d94670920a1c19a4", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^4.15", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^1.5", + "phpstan/phpstan-phpunit": "^1.1", + "phpstan/phpstan-strict-rules": "^1.0", + "phpunit/phpunit": "^9.5", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/1.32.0" + }, + "time": "2024-09-26T07:23:32+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.6.0" +}