diff --git a/lib/Controller/ApiViewsController.php b/lib/Controller/ApiViewsController.php new file mode 100644 index 0000000000..9a69a89509 --- /dev/null +++ b/lib/Controller/ApiViewsController.php @@ -0,0 +1,97 @@ +, array{}> + * + * 200: Widget types returned + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/api/2/views/widget-types')] + public function widgetTypes(): DataResponse { + return new DataResponse(array_values($this->gridWidgetTypes->all())); + } + + /** + * [api v2] Create a grid view, a view without a table + * + * The view belongs to the user who creates it and can be placed in an application menu. + * It is named by its id, so it takes no technical name. + * + * @param string $title Title of the view + * @param string|null $emoji Emoji shown with the title + * @param string $description Description shown on the page + * @return DataResponse|DataResponse + * + * 200: View created + * 400: Invalid title or emoji + * 403: No permissions + */ + #[NoAdminRequired] + #[UserRateLimit(limit: 20, period: 60)] + #[ApiRoute(verb: 'POST', url: '/api/2/views')] + public function create(string $title, ?string $emoji = null, string $description = ''): DataResponse { + try { + $validTitle = (string)new Title($title); + $validEmoji = $emoji === null || $emoji === '' ? null : (string)new Emoji($emoji); + } catch (InvalidArgumentException $e) { + return $this->handleBadRequestError(new BadRequestError($e->getMessage(), $e->getCode(), $e)); + } + try { + return new DataResponse($this->viewService->create($validTitle, $validEmoji, null, $this->userId, null, null, View::TYPE_GRID, $description)->jsonSerialize()); + } catch (PermissionError $e) { + return $this->handlePermissionError($e); + } catch (BadRequestError $e) { + return $this->handleBadRequestError($e); + } catch (InternalError $e) { + return $this->handleError($e); + } + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 379cd36a8f..8d79d7d3b4 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -70,6 +70,23 @@ * grid: array{widgets: list, layout: list}, * } * + * @psalm-type TablesGridWidgetProperty = array{ + * type: 'string'|'text'|'color'|'boolean'|'integer'|'enum'|'target', + * title: string, + * default: TablesGridWidgetValue, + * options?: list, + * required?: bool, + * } + * + * @psalm-type TablesGridWidgetType = array{ + * type: 'header'|'text'|'data', + * title: string, + * defaultWidth: int, + * defaultHeight: int, + * configuration: array, + * properties: array, + * } + * * @psalm-type TablesTable = array{ * id: int, * uuid: string, diff --git a/lib/Service/GridWidgetTypes.php b/lib/Service/GridWidgetTypes.php index 36d8333946..17f9abb187 100644 --- a/lib/Service/GridWidgetTypes.php +++ b/lib/Service/GridWidgetTypes.php @@ -25,8 +25,8 @@ * @psalm-import-type TablesGridWidgetValue from ResponseDefinitions * @psalm-import-type TablesGridWidget from ResponseDefinitions * @psalm-import-type TablesGridLayoutItem from ResponseDefinitions - * @psalm-type TablesGridWidgetProperty = array{type: string, title: string, default: TablesGridWidgetValue, options?: list, required?: bool} - * @psalm-type TablesGridWidgetType = array{type: string, title: string, defaultWidth: int, defaultHeight: int, configuration: array, properties: array} + * @psalm-import-type TablesGridWidgetProperty from ResponseDefinitions + * @psalm-import-type TablesGridWidgetType from ResponseDefinitions */ class GridWidgetTypes { public const TYPE_HEADER = 'header'; diff --git a/openapi.json b/openapi.json index 3c2acd4677..8597a1019f 100644 --- a/openapi.json +++ b/openapi.json @@ -397,6 +397,99 @@ } } }, + "GridWidgetProperty": { + "type": "object", + "required": [ + "type", + "title", + "default" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "string", + "text", + "color", + "boolean", + "integer", + "enum", + "target" + ] + }, + "title": { + "type": "string" + }, + "default": { + "$ref": "#/components/schemas/GridWidgetValue" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "required": [ + "value", + "label" + ], + "properties": { + "value": { + "type": "string" + }, + "label": { + "type": "string" + } + } + } + }, + "required": { + "type": "boolean" + } + } + }, + "GridWidgetType": { + "type": "object", + "required": [ + "type", + "title", + "defaultWidth", + "defaultHeight", + "configuration", + "properties" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "header", + "text", + "data" + ] + }, + "title": { + "type": "string" + }, + "defaultWidth": { + "type": "integer", + "format": "int64" + }, + "defaultHeight": { + "type": "integer", + "format": "int64" + }, + "configuration": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/GridWidgetProperty" + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/GridWidgetProperty" + } + } + } + }, "GridWidgetValue": { "nullable": true, "oneOf": [ @@ -15041,6 +15134,333 @@ } } }, + "/ocs/v2.php/apps/tables/api/2/views/widget-types": { + "get": { + "operationId": "api_views-widget-types", + "summary": "[api v2] The widget types a grid view can hold", + "description": "Every type carries two schemas: the configuration of the widget on the page and its content.", + "tags": [ + "api_views" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "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": { + "200": { + "description": "Widget types returned", + "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/GridWidgetType" + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "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/tables/api/2/views": { + "post": { + "operationId": "api_views-create", + "summary": "[api v2] Create a grid view, a view without a table", + "description": "The view belongs to the user who creates it and can be placed in an application menu. It is named by its id, so it takes no technical name.", + "tags": [ + "api_views" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "title": { + "type": "string", + "description": "Title of the view" + }, + "emoji": { + "type": "string", + "nullable": true, + "default": null, + "description": "Emoji shown with the title" + }, + "description": { + "type": "string", + "default": "", + "description": "Description shown on the page" + } + } + } + } + } + }, + "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": { + "200": { + "description": "View created", + "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/View" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid title or emoji", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "No permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "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/tables/api/2/public/{token}/rows": { "get": { "operationId": "public_rowocs-get-rows", diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 4a228ad7d6..aebdbc0cba 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -981,6 +981,46 @@ export type paths = { readonly patch?: never; readonly trace?: never; }; + readonly "/ocs/v2.php/apps/tables/api/2/views/widget-types": { + readonly parameters: { + readonly query?: never; + readonly header?: never; + readonly path?: never; + readonly cookie?: never; + }; + /** + * [api v2] The widget types a grid view can hold + * @description Every type carries two schemas: the configuration of the widget on the page and its content. + */ + readonly get: operations["api_views-widget-types"]; + readonly put?: never; + readonly post?: never; + readonly delete?: never; + readonly options?: never; + readonly head?: never; + readonly patch?: never; + readonly trace?: never; + }; + readonly "/ocs/v2.php/apps/tables/api/2/views": { + readonly parameters: { + readonly query?: never; + readonly header?: never; + readonly path?: never; + readonly cookie?: never; + }; + readonly get?: never; + readonly put?: never; + /** + * [api v2] Create a grid view, a view without a table + * @description The view belongs to the user who creates it and can be placed in an application menu. It is named by its id, so it takes no technical name. + */ + readonly post: operations["api_views-create"]; + readonly delete?: never; + readonly options?: never; + readonly head?: never; + readonly patch?: never; + readonly trace?: never; + }; readonly "/ocs/v2.php/apps/tables/api/2/public/{token}/rows": { readonly parameters: { readonly query?: never; @@ -1185,6 +1225,32 @@ export type components = { }; readonly broken?: boolean; }; + readonly GridWidgetProperty: { + /** @enum {string} */ + readonly type: "string" | "text" | "color" | "boolean" | "integer" | "enum" | "target"; + readonly title: string; + readonly default: components["schemas"]["GridWidgetValue"]; + readonly options?: readonly { + readonly value: string; + readonly label: string; + }[]; + readonly required?: boolean; + }; + readonly GridWidgetType: { + /** @enum {string} */ + readonly type: "header" | "text" | "data"; + readonly title: string; + /** Format: int64 */ + readonly defaultWidth: number; + /** Format: int64 */ + readonly defaultHeight: number; + readonly configuration: { + readonly [key: string]: components["schemas"]["GridWidgetProperty"]; + }; + readonly properties: { + readonly [key: string]: components["schemas"]["GridWidgetProperty"]; + }; + }; readonly GridWidgetValue: (string | boolean | number | { /** @enum {string} */ readonly type: "table" | "view"; @@ -8172,6 +8238,154 @@ export interface operations { }; }; }; + readonly "api_views-widget-types": { + readonly parameters: { + readonly query?: never; + readonly header: { + /** @description Required to be true for the API request to pass */ + readonly "OCS-APIRequest": boolean; + }; + readonly path?: never; + readonly cookie?: never; + }; + readonly requestBody?: never; + readonly responses: { + /** @description Widget types returned */ + readonly 200: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: readonly components["schemas"]["GridWidgetType"][]; + }; + }; + }; + }; + /** @description Current user is not logged in */ + readonly 401: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: unknown; + }; + }; + }; + }; + }; + }; + readonly "api_views-create": { + readonly parameters: { + readonly query?: never; + readonly header: { + /** @description Required to be true for the API request to pass */ + readonly "OCS-APIRequest": boolean; + }; + readonly path?: never; + readonly cookie?: never; + }; + readonly requestBody: { + readonly content: { + readonly "application/json": { + /** @description Title of the view */ + readonly title: string; + /** + * @description Emoji shown with the title + * @default null + */ + readonly emoji?: string | null; + /** + * @description Description shown on the page + * @default + */ + readonly description?: string; + }; + }; + }; + readonly responses: { + /** @description View created */ + readonly 200: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: components["schemas"]["View"]; + }; + }; + }; + }; + /** @description Invalid title or emoji */ + readonly 400: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + /** @description Current user is not logged in */ + readonly 401: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: unknown; + }; + }; + }; + }; + /** @description No permissions */ + readonly 403: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + readonly 500: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + }; + }; readonly "public_rowocs-get-rows": { readonly parameters: { readonly query?: { diff --git a/tests/unit/Controller/ApiViewsControllerTest.php b/tests/unit/Controller/ApiViewsControllerTest.php new file mode 100644 index 0000000000..6710c5a6ac --- /dev/null +++ b/tests/unit/Controller/ApiViewsControllerTest.php @@ -0,0 +1,102 @@ +viewService = $this->createMock(ViewService::class); + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnArgument(0); + $this->gridWidgetTypes = new GridWidgetTypes($l10n, $this->createMock(ViewMapper::class), $this->createMock(TableMapper::class), $this->createMock(PermissionsService::class)); + $this->controller = new ApiViewsController($this->createMock(IRequest::class), $this->createMock(LoggerInterface::class), $l10n, 'alice', $this->viewService, $this->gridWidgetTypes); + } + + public function testWidgetTypesListsEveryTypeWithBothSchemas(): void { + $types = $this->controller->widgetTypes()->getData(); + + $this->assertCount(count($this->gridWidgetTypes->all()), $types); + foreach ($types as $type) { + $this->assertArrayHasKey('configuration', $type); + $this->assertArrayHasKey('properties', $type); + } + } + + public function testCreateHandsTheStandaloneViewToTheService(): void { + $view = new View(); + $view->setId(6); + $view->setType(View::TYPE_GRID); + $view->setCreatedBy('alice'); + $this->viewService->expects($this->once()) + ->method('create') + ->with('Home', '🧩', null, 'alice', null, null, View::TYPE_GRID, 'Start here') + ->willReturn($view); + + $response = $this->controller->create(' Home ', '🧩', 'Start here'); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame(6, $response->getData()['id']); + } + + public static function invalidInput(): array { + return [ + 'empty title' => [' ', null], + 'title over 200 bytes' => [str_repeat('a', 201), null], + 'emoji that is not one emoji' => ['Home', 'not an emoji'], + ]; + } + + /** + * @dataProvider invalidInput + */ + public function testCreateRefusesInvalidInputWith400(string $title, ?string $emoji): void { + $this->viewService->expects($this->never())->method('create'); + + $response = $this->controller->create($title, $emoji); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + } + + public function testCreateTurnsABadRequestInto400(): void { + $this->viewService->method('create')->willThrowException(new BadRequestError('rejected by the service')); + + $response = $this->controller->create('Home'); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + } + + public function testCreateTurnsAPermissionErrorInto403(): void { + $this->viewService->method('create')->willThrowException(new PermissionError('no')); + + $response = $this->controller->create('Home'); + + $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus()); + } +}