From 6478687f6697c741f053ec633e1f2d7f21785921 Mon Sep 17 00:00:00 2001 From: Rello Date: Thu, 15 Oct 2026 22:12:59 +0200 Subject: [PATCH 1/5] Add OCS endpoints for accessible reports and filtered data Signed-off-by: Rello Assisted-by: Codex:GPT-5 --- .gitignore | 1 + CHANGELOG.md | 3 + docs/ocs-reports-api-v1.md | 45 + lib/Controller/ApiController.php | 66 +- openapi.json | 786 +++++++++++++++++- tests/Controller/ApiControllerTest.php | 81 ++ .../AppFramework/OCS/OCSNotFoundException.php | 9 + .../Stubs/OCP/AppFramework/OCSController.php | 9 + 8 files changed, 985 insertions(+), 15 deletions(-) create mode 100644 docs/ocs-reports-api-v1.md create mode 100644 tests/Controller/ApiControllerTest.php create mode 100644 tests/Stubs/OCP/AppFramework/OCS/OCSNotFoundException.php create mode 100644 tests/Stubs/OCP/AppFramework/OCSController.php diff --git a/.gitignore b/.gitignore index 15138456..7a2a9d9e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ RCS/* tests/ui-artifacts/ docs/* !docs/flexible-storage-api-v1.md +!docs/ocs-reports-api-v1.md # Mac OS .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 99ac7063..bff85882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## Unreleased +### Added +- OCS endpoints to list accessible reports and retrieve report metadata and data with saved filters, including shared reports. + ### Fixed - Update bundled Moment.js to 2.31.0, which includes the fix for CVE-2026-17495. - Update bundled PhpSpreadsheet to 5.10.0 to address known spreadsheet processing vulnerabilities. diff --git a/docs/ocs-reports-api-v1.md b/docs/ocs-reports-api-v1.md new file mode 100644 index 00000000..092135c1 --- /dev/null +++ b/docs/ocs-reports-api-v1.md @@ -0,0 +1,45 @@ + + +# OCS reports API v1 + +All paths below are relative to `/ocs/v2.php/apps/analytics/api/v1`. +Authenticate as a Nextcloud user (for example with HTTP Basic authentication and +an app password), send `OCS-APIRequest: true`, and request JSON with +`Accept: application/json`. Responses use the standard `ocs.meta` / `ocs.data` +envelope. Existing `/apps/analytics/api/1.0` through `4.0` APIs are unchanged. + +| Method | Path | Result in `ocs.data` | +| --- | --- | --- | +| GET | `/reports` | Array of accessible report summaries, excluding folders | +| GET | `/reports/{reportId}` | Report metadata and saved visualization/filter options | +| GET | `/reports/{reportId}/data` | Viewer data payload including rows, headers and report options | + +Access includes owned reports and reports shared with the user, their groups, +or through shared report folders, following the report navigation access rules. +Public-link possession and panorama-only access do not grant standalone report +access through these endpoints. Missing, inaccessible, and folder IDs return +an OCS not-found error (HTTP 404 on OCS v2). + +Metadata excludes datasource connection settings, owner IDs and share secrets. +The data endpoint uses the same backend pipeline as the report viewer, including +saved filters, dynamic filter variables, sorting and transformations for internal +and external datasources. It accepts no temporary filter or visualization +overrides. It returns the viewer's backend data, not rendered chart images or +client-side table formatting/pivot output. Datasource failures retain the viewer +payload's `error` field. Conditional caching is not enabled for this endpoint. + +Example: + +```sh +curl --user 'USER:APP_PASSWORD' \ + --header 'OCS-APIRequest: true' \ + --header 'Accept: application/json' \ + 'https://cloud.example/ocs/v2.php/apps/analytics/api/v1/reports/42/data' +``` + +The machine-readable contract is maintained in the repository root `openapi.json`. +This repository currently has no configured OpenAPI extractor; keep the specification +in sync when changing the controller. diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index ee351b9f..b129b34f 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -9,6 +9,8 @@ namespace OCA\Analytics\Controller; use OCA\Analytics\Service\ReportService; +use OCA\Analytics\Service\ShareService; +use OCP\AppFramework\OCS\OCSNotFoundException; use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; @@ -22,10 +24,13 @@ class ApiController extends OCSController { private ReportService $reportService; private $logger; - public function __construct(string $appName, - IRequest $request, - LoggerInterface $logger, - ReportService $reportService + public function __construct( + string $appName, + IRequest $request, + LoggerInterface $logger, + ReportService $reportService, + private ShareService $shareService, + private OutputController $outputController ) { parent::__construct($appName, $request); $this->reportService = $reportService; @@ -68,4 +73,55 @@ public function createFromDataFile($fileId): DataResponse { return new DataResponse(['error' => 'fileId missing'], HTTP::STATUS_BAD_REQUEST); } } -} \ No newline at end of file + + #[NoAdminRequired] + #[NoCSRFRequired] + #[ApiRoute(verb: 'GET', url: '/api/v1/reports')] + public function index(): DataResponse { + return new DataResponse(array_values(array_filter( + $this->reportService->index(), + static fn (array $report): bool => (int)$report['type'] !== ReportService::REPORT_TYPE_GROUP + ))); + } + + #[NoAdminRequired] + #[NoCSRFRequired] + #[ApiRoute(verb: 'GET', url: '/api/v1/reports/{reportId}')] + public function read(int $reportId): DataResponse { + return new DataResponse($this->publicMetadata($this->accessibleReport($reportId))); + } + + #[NoAdminRequired] + #[NoCSRFRequired] + #[ApiRoute(verb: 'GET', url: '/api/v1/reports/{reportId}/data')] + public function data(int $reportId): DataResponse { + // Use the viewer pipeline with saved options only, without request overrides + // or conditional caching: every successful response contains the report data. + $result = $this->outputController->getData($this->accessibleReport($reportId)); + unset($result['cache']); + if (isset($result['options'])) { + $result['options'] = $this->publicMetadata($result['options']); + } + return new DataResponse($result); + } + + private function accessibleReport(int $reportId): array { + $report = $this->reportService->read($reportId); + if (empty($report)) { + $report = $this->shareService->getSharedReport($reportId); + } + if (empty($report) || (int)$report['type'] === ReportService::REPORT_TYPE_GROUP) { + throw new OCSNotFoundException('Report not found'); + } + return $report; + } + + private function publicMetadata(array $report): array { + return array_intersect_key($report, array_flip([ + 'id', 'name', 'subheader', 'type', 'parent', 'dataset', 'version', + 'visualization', 'chart', 'chartoptions', 'dataoptions', 'tableoptions', + 'filteroptions', 'refresh', 'permissions', 'isShare', + 'storageMode', 'schemaVersion', 'columns', 'dimension1', 'dimension2', 'value', + ])); + } +} diff --git a/openapi.json b/openapi.json index 479f6251..64ddf1f1 100644 --- a/openapi.json +++ b/openapi.json @@ -9,7 +9,9 @@ "post": { "summary": "Create analytics report from a data file", "operationId": "createFromDataFile", - "tags": ["analytics"], + "tags": [ + "analytics" + ], "requestBody": { "required": true, "content": { @@ -22,7 +24,9 @@ "description": "ID of the file to import" } }, - "required": ["fileId"] + "required": [ + "fileId" + ] } } } @@ -35,25 +39,787 @@ "schema": { "type": "object", "properties": { - "version": {"type": "number"}, - "root": { + "ocs": { "type": "object", "properties": { - "orientation": {"type": "string"}, - "rows": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "root": { + "type": "object", + "properties": { + "orientation": { + "type": "string" + }, + "rows": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + } + } + }, + "required": [ + "meta", + "data" + ] + } + }, + "required": [ + "ocs" + ] + } + } + } + }, + "404": { + "description": "File not found" + }, + "400": { + "description": "fileId missing", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ocs": { + "type": "object", + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "required": [ + "meta", + "data" + ] + } + }, + "required": [ + "ocs" + ] + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ocs": { + "type": "object", + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + }, + "required": [ + "meta", + "data" + ] + } + }, + "required": [ + "ocs" + ] + } + } + } + } + }, + "security": [ + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "true" + ] + } + } + ] + } + }, + "/ocs/v2.php/apps/analytics/api/v1/reports": { + "get": { + "operationId": "listReports", + "summary": "List owned and shared reports", + "description": "Includes reports shared with the user, their groups, or through report folders. Excludes public-link-only and panorama-only access.", + "tags": [ + "analytics" + ], + "security": [ + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "true" + ] + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ocs": { + "type": "object", + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { "type": "array", - "items": {"type": "object"} + "items": { + "$ref": "#/components/schemas/ReportSummary" + } } - } + }, + "required": [ + "meta", + "data" + ] } - } + }, + "required": [ + "ocs" + ] + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ocs": { + "type": "object", + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + }, + "required": [ + "meta", + "data" + ] + } + }, + "required": [ + "ocs" + ] + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/analytics/api/v1/reports/{reportId}": { + "get": { + "operationId": "getReport", + "summary": "Get accessible report metadata", + "description": "Includes reports shared with the user, their groups, or through report folders. Excludes public-link-only and panorama-only access.", + "tags": [ + "analytics" + ], + "security": [ + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "true" + ] + } + }, + { + "name": "reportId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Accessible report ID; folders are not reports" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ocs": { + "type": "object", + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ReportMetadata" + } + }, + "required": [ + "meta", + "data" + ] + } + }, + "required": [ + "ocs" + ] + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ocs": { + "type": "object", + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + }, + "required": [ + "meta", + "data" + ] + } + }, + "required": [ + "ocs" + ] + } + } + } + }, + "404": { + "description": "Report missing or inaccessible", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ocs": { + "type": "object", + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + }, + "required": [ + "meta", + "data" + ] + } + }, + "required": [ + "ocs" + ] + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/analytics/api/v1/reports/{reportId}/data": { + "get": { + "operationId": "getReportData", + "summary": "Get report data with saved filters", + "description": "Includes reports shared with the user, their groups, or through report folders. Excludes public-link-only and panorama-only access. Uses the report viewer backend pipeline, including saved filters, variables, sorting and transformations. No temporary filter overrides. Browser-side formatting and pivots are not rendered. Datasource failures use the payload error field.", + "tags": [ + "analytics" + ], + "security": [ + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "true" + ] + } + }, + { + "name": "reportId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Accessible report ID; folders are not reports" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ocs": { + "type": "object", + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ReportData" + } + }, + "required": [ + "meta", + "data" + ] + } + }, + "required": [ + "ocs" + ] + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ocs": { + "type": "object", + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + }, + "required": [ + "meta", + "data" + ] + } + }, + "required": [ + "ocs" + ] } } } }, - "404": {"description": "File not found"} + "404": { + "description": "Report missing or inaccessible", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ocs": { + "type": "object", + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + }, + "required": [ + "meta", + "data" + ] + } + }, + "required": [ + "ocs" + ] + } + } + } + } } } } + }, + "components": { + "securitySchemes": { + "basic_auth": { + "type": "http", + "scheme": "basic" + } + }, + "schemas": { + "OCSMeta": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "statuscode": { + "type": "integer" + }, + "message": { + "type": "string" + } + }, + "required": [ + "status", + "statuscode", + "message" + ] + }, + "ReportSummary": { + "type": "object", + "properties": { + "id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "type": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "dataset": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "parent": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "version": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "isShare": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "name": { + "type": "string", + "nullable": true + }, + "favorite": { + "type": "integer" + }, + "item_type": { + "type": "string" + }, + "shareId": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "id", + "type", + "name" + ] + }, + "ReportMetadata": { + "type": "object", + "properties": { + "id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "type": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "dataset": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "parent": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "version": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "permissions": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "isShare": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "schemaVersion": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "refresh": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "name": { + "type": "string", + "nullable": true + }, + "subheader": { + "type": "string", + "nullable": true + }, + "visualization": { + "type": "string", + "nullable": true + }, + "chart": { + "type": "string", + "nullable": true + }, + "chartoptions": { + "type": "string", + "nullable": true + }, + "dataoptions": { + "type": "string", + "nullable": true + }, + "tableoptions": { + "type": "string", + "nullable": true + }, + "filteroptions": { + "type": "string", + "nullable": true + }, + "storageMode": { + "type": "string", + "nullable": true + }, + "dimension1": { + "type": "string", + "nullable": true + }, + "dimension2": { + "type": "string", + "nullable": true + }, + "value": { + "type": "string", + "nullable": true + }, + "columns": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "ReportData": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "header": { + "type": "array", + "items": { + "type": "string" + } + }, + "options": { + "$ref": "#/components/schemas/ReportMetadata" + }, + "filterApplied": { + "type": "string", + "nullable": true + }, + "error": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "thresholds": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "additionalProperties": true + } + } } } diff --git a/tests/Controller/ApiControllerTest.php b/tests/Controller/ApiControllerTest.php new file mode 100644 index 00000000..ed0b7ec3 --- /dev/null +++ b/tests/Controller/ApiControllerTest.php @@ -0,0 +1,81 @@ +createMock(IRequest::class), $this->createMock(\Psr\Log\LoggerInterface::class), $reports, $shares, $output); + } + + public function testListIncludesSharedReportsAndOmitsFolders(): void { + $reports = $this->createMock(ReportService::class); + $reports->method('index')->willReturn([ + ['id' => 1, 'type' => 2], ['id' => 2, 'type' => 0], + ['id' => 3, 'type' => 2, 'isShare' => 1], + ]); + $result = $this->controller($reports, $this->createMock(ShareService::class), $this->createMock(OutputController::class))->index()->getData(); + $this->assertSame([1, 3], array_column($result, 'id')); + $this->assertSame([0, 1], array_keys($result)); + } + + public function testSharedMetadataDoesNotExposeConnectionOrShareSecrets(): void { + $reports = $this->createMock(ReportService::class); + $reports->method('read')->with(3)->willReturn([]); + $shares = $this->createMock(ShareService::class); + $shares->method('getSharedReport')->with(3)->willReturn([ + 'id' => 3, 'type' => 2, 'name' => 'Shared', 'filteroptions' => '{"filter":[]}', + 'link' => 'credentials', 'password' => 'hash', 'token' => 'secret', 'user_id' => 'owner', + ]); + $result = $this->controller($reports, $shares, $this->createMock(OutputController::class))->read(3)->getData(); + $this->assertSame(['id', 'type', 'name', 'filteroptions'], array_keys($result)); + } + + public function testDataUsesViewerPipelineWithSavedSharedFilters(): void { + $metadata = ['id' => 3, 'type' => 2, 'filteroptions' => '{"filter":{"dimension1":"saved"}}']; + $reports = $this->createMock(ReportService::class); + $reports->method('read')->willReturn([]); + $shares = $this->createMock(ShareService::class); + $shares->method('getSharedReport')->with(3)->willReturn($metadata); + $output = $this->createMock(OutputController::class); + $output->expects($this->once())->method('getData')->with($metadata)->willReturn([ + 'header' => ['Label', 'Value'], 'data' => [['saved', 42]], + 'filterApplied' => $metadata['filteroptions'], 'options' => $metadata + ['token' => 'secret'], + 'cache' => ['key' => 'internal'], + ]); + $result = $this->controller($reports, $shares, $output)->data(3)->getData(); + $this->assertSame([['saved', 42]], $result['data']); + $this->assertSame($metadata['filteroptions'], $result['filterApplied']); + $this->assertArrayNotHasKey('cache', $result); + $this->assertArrayNotHasKey('token', $result['options']); + } + + public function testInaccessibleReportNeverExecutesDatasource(): void { + $reports = $this->createMock(ReportService::class); + $reports->method('read')->willReturn([]); + $shares = $this->createMock(ShareService::class); + $shares->method('getSharedReport')->willReturn([]); + $output = $this->createMock(OutputController::class); + $output->expects($this->never())->method('getData'); + $this->expectException(OCSNotFoundException::class); + $this->controller($reports, $shares, $output)->data(99); + } + + public function testOwnedMetadataDoesNotRequireShare(): void { + $reports = $this->createMock(ReportService::class); + $reports->method('read')->with(1)->willReturn(['id' => 1, 'type' => 2]); + $shares = $this->createMock(ShareService::class); + $shares->expects($this->never())->method('getSharedReport'); + $this->assertSame(1, $this->controller($reports, $shares, $this->createMock(OutputController::class))->read(1)->getData()['id']); + } +} diff --git a/tests/Stubs/OCP/AppFramework/OCS/OCSNotFoundException.php b/tests/Stubs/OCP/AppFramework/OCS/OCSNotFoundException.php new file mode 100644 index 00000000..aab20ab0 --- /dev/null +++ b/tests/Stubs/OCP/AppFramework/OCS/OCSNotFoundException.php @@ -0,0 +1,9 @@ + Date: Thu, 15 Oct 2026 22:27:24 +0200 Subject: [PATCH 2/5] Expose existing dataset operations through OCS Signed-off-by: Rello Assisted-by: Codex:GPT-5 --- CHANGELOG.md | 2 +- docs/ocs-reports-api-v1.md | 34 +++ lib/Controller/ApiController.php | 27 +- openapi.json | 390 +++++++++++++++++++++++++ tests/Controller/ApiControllerTest.php | 36 ++- 5 files changed, 483 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bff85882..9d827fc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased ### Added -- OCS endpoints to list accessible reports and retrieve report metadata and data with saved filters, including shared reports. +- OCS endpoints to list accessible reports and owned datasets, retrieve report metadata and filtered data including shared reports, and add or delete dataset rows. ### Fixed - Update bundled Moment.js to 2.31.0, which includes the fix for CVE-2026-17495. diff --git a/docs/ocs-reports-api-v1.md b/docs/ocs-reports-api-v1.md index 092135c1..a1f747eb 100644 --- a/docs/ocs-reports-api-v1.md +++ b/docs/ocs-reports-api-v1.md @@ -43,3 +43,37 @@ curl --user 'USER:APP_PASSWORD' \ The machine-readable contract is maintained in the repository root `openapi.json`. This repository currently has no configured OpenAPI extractor; keep the specification in sync when changing the controller. + +## Dataset operations + +| Method | Path | Result in `ocs.data` | +| --- | --- | --- | +| GET | `/datasets` | Existing V3 list of owned datasets | +| POST | `/datasets/{datasetId}/data/add` | Existing V4 add/update result | +| POST | `/datasets/{datasetId}/data/delete` | Existing V4 deletion result, including `delete` count | + +The OCS controller delegates these operations to the existing external API handlers. +Dataset writes require ownership and legacy storage; shared report access does not +allow dataset writes. Flexible datasets continue to use the stable-column record API. +The URL dataset ID takes precedence over any body parameter. + +Add/update body (technical keys or configured dataset column names): + +```json +{"data":[{"dimension1":"2026-10-15","dimension2":"Sales","value":"42"}]} +``` + +Delete body (a nonempty filter is required): + +```json +{"filter":{"dimension1":{"option":"EQ","value":"2026-10-15"}}} +``` + +Deletion supports `dimension1`, `dimension2`, or their configured names, and the +V4 operators `EQ`, `GT`, `LT`, `IN`, `LIKE`, `NOTLIKE`, and `BETWEEN`, including +dynamic date variables. These endpoints retain V4 application error semantics: +HTTP 200 with `ocs.data.success=false` and `ocs.data.error.code/message`. +Codes are 9001 (unknown), 9002 (invalid input), 9003 (missing or unauthorized +dataset), and 9004 (unsupported storage mode). Check `success`, not just the OCS +metadata. Successful writes return `success=true` and a message; deletion also +returns the deleted row count. Authentication failures remain HTTP 401. diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index b129b34f..db5d4f29 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -30,7 +30,8 @@ public function __construct( LoggerInterface $logger, ReportService $reportService, private ShareService $shareService, - private OutputController $outputController + private OutputController $outputController, + private ApiDataController $apiDataController ) { parent::__construct($appName, $request); $this->reportService = $reportService; @@ -105,6 +106,30 @@ public function data(int $reportId): DataResponse { return new DataResponse($result); } + /** List owned datasets using the existing external API contract. */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[ApiRoute(verb: 'GET', url: '/api/v1/datasets')] + public function datasets(): DataResponse { + return new DataResponse($this->apiDataController->datasetIndexV3()); + } + + /** Add or update rows using the existing V4 validation and write path. */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[ApiRoute(verb: 'POST', url: '/api/v1/datasets/{datasetId}/data/add')] + public function addData(int $datasetId): DataResponse { + return $this->apiDataController->addDataV4($datasetId); + } + + /** Delete rows using the existing V4 structured-filter write path. */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[ApiRoute(verb: 'POST', url: '/api/v1/datasets/{datasetId}/data/delete')] + public function deleteData(int $datasetId): DataResponse { + return $this->apiDataController->deleteDataV4($datasetId); + } + private function accessibleReport(int $reportId): array { $report = $this->reportService->read($reportId); if (empty($report)) { diff --git a/openapi.json b/openapi.json index 64ddf1f1..5ad25fe2 100644 --- a/openapi.json +++ b/openapi.json @@ -508,6 +508,357 @@ } } } + }, + "/ocs/v2.php/apps/analytics/api/v1/datasets": { + "get": { + "operationId": "listDatasets", + "summary": "List owned datasets", + "description": "Same payload and ownership scope as the existing V3 dataset list. Shared reports do not grant dataset write access.", + "tags": [ + "analytics" + ], + "security": [ + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "true" + ] + } + } + ], + "responses": { + "200": { + "description": "Owned datasets", + "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": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ocs": { + "type": "object", + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + }, + "required": [ + "meta", + "data" + ] + } + }, + "required": [ + "ocs" + ] + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/analytics/api/v1/datasets/{datasetId}/data/add": { + "post": { + "operationId": "addDatasetData", + "summary": "Add or update dataset rows", + "description": "Delegates to the existing V4 external API. Requires ownership of a legacy dataset; flexible datasets are rejected. Application errors retain HTTP 200 and success=false with error.code and error.message in ocs.data (9002 invalid input, 9003 missing or unauthorized dataset, 9004 unsupported storage mode). Inspect success, not only ocs.meta. The URL datasetId takes precedence over body parameters.", + "tags": [ + "analytics" + ], + "security": [ + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "true" + ] + } + }, + { + "name": "datasetId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Existing V4 operation result; inspect success for application errors", + "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/DataWriteResult" + } + } + } + } + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ocs": { + "type": "object", + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + }, + "required": [ + "meta", + "data" + ] + } + }, + "required": [ + "ocs" + ] + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": {}, + "description": "Provide dimension1, dimension2 and value using technical keys or configured dataset column names." + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/analytics/api/v1/datasets/{datasetId}/data/delete": { + "post": { + "operationId": "deleteDatasetData", + "summary": "Delete dataset rows matching a filter", + "description": "Delegates to the existing V4 external API. Requires ownership of a legacy dataset; flexible datasets are rejected. Application errors retain HTTP 200 and success=false with error.code and error.message in ocs.data (9002 invalid input, 9003 missing or unauthorized dataset, 9004 unsupported storage mode). Inspect success, not only ocs.meta. The URL datasetId takes precedence over body parameters.", + "tags": [ + "analytics" + ], + "security": [ + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "true" + ] + } + }, + { + "name": "datasetId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Existing V4 operation result; inspect success for application errors", + "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/DataWriteResult" + } + } + } + } + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ocs": { + "type": "object", + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + }, + "required": [ + "meta", + "data" + ] + } + }, + "required": [ + "ocs" + ] + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "filter" + ], + "properties": { + "filter": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "type": "object", + "required": [ + "option", + "value" + ], + "properties": { + "option": { + "type": "string", + "enum": [ + "EQ", + "GT", + "LT", + "IN", + "LIKE", + "NOTLIKE", + "BETWEEN" + ] + }, + "value": { + "type": "string" + } + } + }, + "description": "Keys are dimension1/dimension2 or their configured names. Values support the existing V4 filter syntax and date variables." + } + } + } + } + } + } + } } }, "components": { @@ -819,6 +1170,45 @@ } }, "additionalProperties": true + }, + "DataWriteResult": { + "type": "object", + "required": [ + "success" + ], + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "delete": { + "type": "integer", + "description": "Number of deleted rows" + }, + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "integer", + "enum": [ + 9001, + 9002, + 9003, + 9004 + ] + }, + "message": { + "type": "string" + } + } + } + } } } } diff --git a/tests/Controller/ApiControllerTest.php b/tests/Controller/ApiControllerTest.php index ed0b7ec3..83be611b 100644 --- a/tests/Controller/ApiControllerTest.php +++ b/tests/Controller/ApiControllerTest.php @@ -6,6 +6,8 @@ namespace OCA\Analytics\Tests\Controller; use OCA\Analytics\Controller\ApiController; +use OCA\Analytics\Controller\ApiDataController; +use OCP\AppFramework\Http\DataResponse; use OCA\Analytics\Controller\OutputController; use OCA\Analytics\Service\ReportService; use OCA\Analytics\Service\ShareService; @@ -14,8 +16,8 @@ use PHPUnit\Framework\TestCase; class ApiControllerTest extends TestCase { - private function controller($reports, $shares, $output): ApiController { - return new ApiController('analytics', $this->createMock(IRequest::class), $this->createMock(\Psr\Log\LoggerInterface::class), $reports, $shares, $output); + private function controller($reports, $shares, $output, $apiData = null): ApiController { + return new ApiController('analytics', $this->createMock(IRequest::class), $this->createMock(\Psr\Log\LoggerInterface::class), $reports, $shares, $output, $apiData ?? $this->createMock(ApiDataController::class)); } public function testListIncludesSharedReportsAndOmitsFolders(): void { @@ -53,7 +55,7 @@ public function testDataUsesViewerPipelineWithSavedSharedFilters(): void { 'filterApplied' => $metadata['filteroptions'], 'options' => $metadata + ['token' => 'secret'], 'cache' => ['key' => 'internal'], ]); - $result = $this->controller($reports, $shares, $output)->data(3)->getData(); + $result = $this->controller($reports, $shares, $output, $apiData = null)->data(3)->getData(); $this->assertSame([['saved', 42]], $result['data']); $this->assertSame($metadata['filteroptions'], $result['filterApplied']); $this->assertArrayNotHasKey('cache', $result); @@ -68,7 +70,7 @@ public function testInaccessibleReportNeverExecutesDatasource(): void { $output = $this->createMock(OutputController::class); $output->expects($this->never())->method('getData'); $this->expectException(OCSNotFoundException::class); - $this->controller($reports, $shares, $output)->data(99); + $this->controller($reports, $shares, $output, $apiData = null)->data(99); } public function testOwnedMetadataDoesNotRequireShare(): void { @@ -78,4 +80,30 @@ public function testOwnedMetadataDoesNotRequireShare(): void { $shares->expects($this->never())->method('getSharedReport'); $this->assertSame(1, $this->controller($reports, $shares, $this->createMock(OutputController::class))->read(1)->getData()['id']); } + + public function testDatasetListingUsesExistingExternalApi(): void { + $apiData = $this->createMock(ApiDataController::class); + $datasets = [['id' => 4, 'name' => 'Owned dataset']]; + $apiData->expects($this->once())->method('datasetIndexV3')->willReturn($datasets); + $controller = $this->controller($this->createMock(ReportService::class), $this->createMock(ShareService::class), $this->createMock(OutputController::class), $apiData); + $this->assertSame($datasets, $controller->datasets()->getData()); + } + + /** @dataProvider writeOperations */ + public function testWritesPreserveExistingSuccessAndErrorResponses(string $method, string $delegate, array $payload): void { + $apiData = $this->createMock(ApiDataController::class); + $response = new DataResponse($payload); + $apiData->expects($this->once())->method($delegate)->with(42)->willReturn($response); + $controller = $this->controller($this->createMock(ReportService::class), $this->createMock(ShareService::class), $this->createMock(OutputController::class), $apiData); + $this->assertSame($response, $controller->$method(42)); + } + + public function writeOperations(): array { + return [ + ['addData', 'addDataV4', ['success' => true, 'message' => 'Data update successful']], + ['deleteData', 'deleteDataV4', ['success' => true, 'delete' => 2]], + ['addData', 'addDataV4', ['success' => false, 'error' => ['code' => 9003, 'message' => 'Unknown or unauthorized report or dataset']]], + ['deleteData', 'deleteDataV4', ['success' => false, 'error' => ['code' => 9002, 'message' => 'filter required']]], + ]; + } } From 6b7ee3d2ea44c24369a5ce3dc3950f4f68ec98f1 Mon Sep 17 00:00:00 2001 From: Rello Date: Thu, 15 Oct 2026 22:31:52 +0200 Subject: [PATCH 3/5] Limit OCS dataset summaries to public fields Signed-off-by: Rello Assisted-by: Codex:GPT-5 --- docs/ocs-reports-api-v1.md | 5 +++- lib/Controller/ApiController.php | 9 +++++-- openapi.json | 36 ++++++++++++++++++++++++-- tests/Controller/ApiControllerTest.php | 9 +++++-- 4 files changed, 52 insertions(+), 7 deletions(-) diff --git a/docs/ocs-reports-api-v1.md b/docs/ocs-reports-api-v1.md index a1f747eb..67859587 100644 --- a/docs/ocs-reports-api-v1.md +++ b/docs/ocs-reports-api-v1.md @@ -48,11 +48,14 @@ in sync when changing the controller. | Method | Path | Result in `ocs.data` | | --- | --- | --- | -| GET | `/datasets` | Existing V3 list of owned datasets | +| GET | `/datasets` | Owned datasets: `id`, `name`, `dimension1`, `dimension2`, `value` | | POST | `/datasets/{datasetId}/data/add` | Existing V4 add/update result | | POST | `/datasets/{datasetId}/data/delete` | Existing V4 deletion result, including `delete` count | The OCS controller delegates these operations to the existing external API handlers. +The dataset list exposes only `id`, `name`, `dimension1`, `dimension2`, and `value`; +internal navigation, scheduling, and storage fields are omitted. The legacy V3 +dataset response is unchanged. Dataset writes require ownership and legacy storage; shared report access does not allow dataset writes. Flexible datasets continue to use the stable-column record API. The URL dataset ID takes precedence over any body parameter. diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index db5d4f29..42588314 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -106,12 +106,17 @@ public function data(int $reportId): DataResponse { return new DataResponse($result); } - /** List owned datasets using the existing external API contract. */ + /** List owned datasets with their column labels. */ #[NoAdminRequired] #[NoCSRFRequired] #[ApiRoute(verb: 'GET', url: '/api/v1/datasets')] public function datasets(): DataResponse { - return new DataResponse($this->apiDataController->datasetIndexV3()); + return new DataResponse(array_map( + static fn (array $dataset): array => array_intersect_key($dataset, array_flip([ + 'id', 'name', 'dimension1', 'dimension2', 'value', + ])), + $this->apiDataController->datasetIndexV3() + )); } /** Add or update rows using the existing V4 validation and write path. */ diff --git a/openapi.json b/openapi.json index 5ad25fe2..cd0d0b26 100644 --- a/openapi.json +++ b/openapi.json @@ -513,7 +513,7 @@ "get": { "operationId": "listDatasets", "summary": "List owned datasets", - "description": "Same payload and ownership scope as the existing V3 dataset list. Shared reports do not grant dataset write access.", + "description": "List owned datasets with only their ID, name and column labels. Shared reports do not grant dataset write access. The legacy V3 dataset response is unchanged.", "tags": [ "analytics" ], @@ -560,7 +560,39 @@ "type": "array", "items": { "type": "object", - "additionalProperties": true + "additionalProperties": false, + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "name": { + "type": "string", + "nullable": true + }, + "dimension1": { + "type": "string", + "nullable": true + }, + "dimension2": { + "type": "string", + "nullable": true + }, + "value": { + "type": "string", + "nullable": true + } + } } } } diff --git a/tests/Controller/ApiControllerTest.php b/tests/Controller/ApiControllerTest.php index 83be611b..24d23949 100644 --- a/tests/Controller/ApiControllerTest.php +++ b/tests/Controller/ApiControllerTest.php @@ -83,10 +83,15 @@ public function testOwnedMetadataDoesNotRequireShare(): void { public function testDatasetListingUsesExistingExternalApi(): void { $apiData = $this->createMock(ApiDataController::class); - $datasets = [['id' => 4, 'name' => 'Owned dataset']]; + $expected = [['id' => 4, 'name' => 'Owned dataset', 'dimension1' => 'Object', 'dimension2' => 'Date', 'value' => 'Value']]; + $datasets = [$expected[0] + [ + 'type' => 2, 'parent' => null, 'storage_mode' => 'legacy', 'schema_version' => 0, + 'schedules' => 1, 'item_type' => 'dataset', 'storageMode' => 'legacy', 'schemaVersion' => 0, + 'futureInternalField' => 'must not leak', + ]]; $apiData->expects($this->once())->method('datasetIndexV3')->willReturn($datasets); $controller = $this->controller($this->createMock(ReportService::class), $this->createMock(ShareService::class), $this->createMock(OutputController::class), $apiData); - $this->assertSame($datasets, $controller->datasets()->getData()); + $this->assertSame($expected, $controller->datasets()->getData()); } /** @dataProvider writeOperations */ From a0ec44b6147b2d7d4b503753deca4e54fbdc51e2 Mon Sep 17 00:00:00 2001 From: Rello Date: Thu, 15 Oct 2026 22:33:12 +0200 Subject: [PATCH 4/5] Limit OCS report summaries to ID and name Signed-off-by: Rello Assisted-by: Codex:GPT-5 --- docs/ocs-reports-api-v1.md | 2 +- lib/Controller/ApiController.php | 8 ++- openapi.json | 79 ++------------------------ tests/Controller/ApiControllerTest.php | 6 +- 4 files changed, 16 insertions(+), 79 deletions(-) diff --git a/docs/ocs-reports-api-v1.md b/docs/ocs-reports-api-v1.md index 67859587..940aa248 100644 --- a/docs/ocs-reports-api-v1.md +++ b/docs/ocs-reports-api-v1.md @@ -13,7 +13,7 @@ envelope. Existing `/apps/analytics/api/1.0` through `4.0` APIs are unchanged. | Method | Path | Result in `ocs.data` | | --- | --- | --- | -| GET | `/reports` | Array of accessible report summaries, excluding folders | +| GET | `/reports` | Accessible reports with only `id` and `name`, excluding folders | | GET | `/reports/{reportId}` | Report metadata and saved visualization/filter options | | GET | `/reports/{reportId}/data` | Viewer data payload including rows, headers and report options | diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 42588314..2ef9c142 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -79,10 +79,14 @@ public function createFromDataFile($fileId): DataResponse { #[NoCSRFRequired] #[ApiRoute(verb: 'GET', url: '/api/v1/reports')] public function index(): DataResponse { - return new DataResponse(array_values(array_filter( + $reports = array_values(array_filter( $this->reportService->index(), static fn (array $report): bool => (int)$report['type'] !== ReportService::REPORT_TYPE_GROUP - ))); + )); + return new DataResponse(array_map( + static fn (array $report): array => array_intersect_key($report, array_flip(['id', 'name'])), + $reports + )); } #[NoAdminRequired] diff --git a/openapi.json b/openapi.json index cd0d0b26..9580c2e2 100644 --- a/openapi.json +++ b/openapi.json @@ -922,6 +922,11 @@ }, "ReportSummary": { "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name" + ], "properties": { "id": { "oneOf": [ @@ -933,82 +938,10 @@ } ] }, - "type": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "string" - } - ] - }, - "dataset": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "string" - } - ] - }, - "parent": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "string" - } - ] - }, - "version": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "string" - } - ] - }, - "isShare": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "string" - } - ] - }, "name": { - "type": "string", - "nullable": true - }, - "favorite": { - "type": "integer" - }, - "item_type": { "type": "string" - }, - "shareId": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "string" - } - ] } - }, - "required": [ - "id", - "type", - "name" - ] + } }, "ReportMetadata": { "type": "object", diff --git a/tests/Controller/ApiControllerTest.php b/tests/Controller/ApiControllerTest.php index 24d23949..47543d57 100644 --- a/tests/Controller/ApiControllerTest.php +++ b/tests/Controller/ApiControllerTest.php @@ -23,11 +23,11 @@ private function controller($reports, $shares, $output, $apiData = null): ApiCon public function testListIncludesSharedReportsAndOmitsFolders(): void { $reports = $this->createMock(ReportService::class); $reports->method('index')->willReturn([ - ['id' => 1, 'type' => 2], ['id' => 2, 'type' => 0], - ['id' => 3, 'type' => 2, 'isShare' => 1], + ['id' => 1, 'name' => 'Owned', 'type' => 2, 'parent' => 0, 'dataset' => 2, 'version' => 23495, 'favorite' => 0, 'item_type' => 'report'], ['id' => 2, 'type' => 0], + ['id' => 3, 'name' => 'Shared', 'type' => 2, 'isShare' => 1, 'futureInternalField' => true], ]); $result = $this->controller($reports, $this->createMock(ShareService::class), $this->createMock(OutputController::class))->index()->getData(); - $this->assertSame([1, 3], array_column($result, 'id')); + $this->assertSame([['id' => 1, 'name' => 'Owned'], ['id' => 3, 'name' => 'Shared']], $result); $this->assertSame([0, 1], array_keys($result)); } From 2efa8217c5dbe413c942dae450b8a8aa19e25183 Mon Sep 17 00:00:00 2001 From: Rello Date: Thu, 15 Oct 2026 22:34:12 +0200 Subject: [PATCH 5/5] Keep dataset ID in OCS report summaries Signed-off-by: Rello Assisted-by: Codex:GPT-5 --- docs/ocs-reports-api-v1.md | 2 +- lib/Controller/ApiController.php | 2 +- openapi.json | 13 ++++++++++++- tests/Controller/ApiControllerTest.php | 4 ++-- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/docs/ocs-reports-api-v1.md b/docs/ocs-reports-api-v1.md index 940aa248..037ed87b 100644 --- a/docs/ocs-reports-api-v1.md +++ b/docs/ocs-reports-api-v1.md @@ -13,7 +13,7 @@ envelope. Existing `/apps/analytics/api/1.0` through `4.0` APIs are unchanged. | Method | Path | Result in `ocs.data` | | --- | --- | --- | -| GET | `/reports` | Accessible reports with only `id` and `name`, excluding folders | +| GET | `/reports` | Accessible reports with only `id`, `name`, and `dataset`, excluding folders | | GET | `/reports/{reportId}` | Report metadata and saved visualization/filter options | | GET | `/reports/{reportId}/data` | Viewer data payload including rows, headers and report options | diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 2ef9c142..ac1cdd49 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -84,7 +84,7 @@ public function index(): DataResponse { static fn (array $report): bool => (int)$report['type'] !== ReportService::REPORT_TYPE_GROUP )); return new DataResponse(array_map( - static fn (array $report): array => array_intersect_key($report, array_flip(['id', 'name'])), + static fn (array $report): array => array_intersect_key($report, array_flip(['id', 'name', 'dataset'])), $reports )); } diff --git a/openapi.json b/openapi.json index 9580c2e2..f2a92246 100644 --- a/openapi.json +++ b/openapi.json @@ -925,7 +925,8 @@ "additionalProperties": false, "required": [ "id", - "name" + "name", + "dataset" ], "properties": { "id": { @@ -940,6 +941,16 @@ }, "name": { "type": "string" + }, + "dataset": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] } } }, diff --git a/tests/Controller/ApiControllerTest.php b/tests/Controller/ApiControllerTest.php index 47543d57..e0c041f8 100644 --- a/tests/Controller/ApiControllerTest.php +++ b/tests/Controller/ApiControllerTest.php @@ -24,10 +24,10 @@ public function testListIncludesSharedReportsAndOmitsFolders(): void { $reports = $this->createMock(ReportService::class); $reports->method('index')->willReturn([ ['id' => 1, 'name' => 'Owned', 'type' => 2, 'parent' => 0, 'dataset' => 2, 'version' => 23495, 'favorite' => 0, 'item_type' => 'report'], ['id' => 2, 'type' => 0], - ['id' => 3, 'name' => 'Shared', 'type' => 2, 'isShare' => 1, 'futureInternalField' => true], + ['id' => 3, 'name' => 'Shared', 'dataset' => 7, 'type' => 2, 'isShare' => 1, 'futureInternalField' => true], ]); $result = $this->controller($reports, $this->createMock(ShareService::class), $this->createMock(OutputController::class))->index()->getData(); - $this->assertSame([['id' => 1, 'name' => 'Owned'], ['id' => 3, 'name' => 'Shared']], $result); + $this->assertSame([['id' => 1, 'name' => 'Owned', 'dataset' => 2], ['id' => 3, 'name' => 'Shared', 'dataset' => 7]], $result); $this->assertSame([0, 1], array_keys($result)); }