diff --git a/companion/lib/Instance/Connection/ConnectionsRestApi.ts b/companion/lib/Instance/Connection/ConnectionsRestApi.ts index 084c1c6a99..53a3319939 100644 --- a/companion/lib/Instance/Connection/ConnectionsRestApi.ts +++ b/companion/lib/Instance/Connection/ConnectionsRestApi.ts @@ -15,6 +15,7 @@ import { collectionResponse, createCollectionSchema, createSuccessSchema, + errorResponses, ErrorResponseSchema, successResponse, } from '../../Service/RestApi/schemas/common.js' @@ -371,13 +372,6 @@ const connectionIdParam = z.object({ .meta({ example: 'KJA1isEECHRDBTFjx-7tf' }), }) -const errorResponses = { - 400: { description: 'Bad request', content: { 'application/json': { schema: ErrorResponseSchema } } }, - 401: { description: 'Unauthorized', content: { 'application/json': { schema: ErrorResponseSchema } } }, - 403: { description: 'Forbidden', content: { 'application/json': { schema: ErrorResponseSchema } } }, - 404: { description: 'Not found', content: { 'application/json': { schema: ErrorResponseSchema } } }, -} - const connectionListQuery = z.object({ include_config: z.enum(['true', 'false']).optional().describe('Include connection config in response').meta({ example: 'true', diff --git a/companion/lib/Service/RestApi/RestApiRouter.ts b/companion/lib/Service/RestApi/RestApiRouter.ts index 73967a82a8..c396b27643 100644 --- a/companion/lib/Service/RestApi/RestApiRouter.ts +++ b/companion/lib/Service/RestApi/RestApiRouter.ts @@ -34,6 +34,7 @@ export function createRestApiRouter( // Mount resource routers — each versioned independently router.use(createAuthMiddleware(logger, tokenStore)) router.use(registry.instance.createRestApiRouter(logger)) + router.use(registry.surfaces.createRestApiRouter(logger)) // Do not allow unknown v2 routes to fall through into the legacy /api router. router.use((_req, _res, next) => next(RestApiError.notFound())) diff --git a/companion/lib/Service/RestApi/openapi.ts b/companion/lib/Service/RestApi/openapi.ts index 72d32f8cb0..65a526f539 100644 --- a/companion/lib/Service/RestApi/openapi.ts +++ b/companion/lib/Service/RestApi/openapi.ts @@ -1,6 +1,7 @@ import { OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi' import { registerInstanceRestApiPaths } from '../../Instance/RestApi.js' import type { AppInfo } from '../../Registry.js' +import { registerSurfacePaths } from '../../Surface/SurfacesRestApi.js' import { REST_API_BASE_PATH } from './constants.js' import { createOpenApiRegistry } from './registry.js' @@ -15,6 +16,7 @@ export function generateOpenApiDocument( // Register all route paths into the registry registerInstanceRestApiPaths(registry) + registerSurfacePaths(registry) const generator = new OpenApiGeneratorV3(registry.definitions) diff --git a/companion/lib/Service/RestApi/schemas/common.ts b/companion/lib/Service/RestApi/schemas/common.ts index d9cf0945dd..c0420300d6 100644 --- a/companion/lib/Service/RestApi/schemas/common.ts +++ b/companion/lib/Service/RestApi/schemas/common.ts @@ -20,6 +20,14 @@ export const ErrorResponseSchema = z }) .meta({ example: { error: { code: 'unauthorized', message: 'Missing bearer token' } } }) +/** Error responses returned by every endpoint, for OpenAPI docs */ +export const errorResponses = { + 400: { description: 'Bad request', content: { 'application/json': { schema: ErrorResponseSchema } } }, + 401: { description: 'Unauthorized', content: { 'application/json': { schema: ErrorResponseSchema } } }, + 403: { description: 'Forbidden', content: { 'application/json': { schema: ErrorResponseSchema } } }, + 404: { description: 'Not found', content: { 'application/json': { schema: ErrorResponseSchema } } }, +} + /** Create a typed single-item success envelope schema for OpenAPI docs */ export function createSuccessSchema(itemSchema: T): z.ZodObject<{ data: T }> { const schema = z.object({ data: itemSchema }) diff --git a/companion/lib/Surface/Controller.ts b/companion/lib/Surface/Controller.ts index 0ca8ccdfc3..9d862dc425 100644 --- a/companion/lib/Surface/Controller.ts +++ b/companion/lib/Surface/Controller.ts @@ -13,6 +13,7 @@ import { createHash } from 'node:crypto' import { EventEmitter } from 'node:events' import debounceFn from 'debounce-fn' +import type express from 'express' import jsonPatch from 'fast-json-patch' import HID from 'node-hid' import pDebounce from 'p-debounce' @@ -45,7 +46,7 @@ import { type SurfaceOpener, } from '../Instance/Surface/DiscoveredSurfaceRegistry.js' import type { CheckDeviceInfo } from '../Instance/Surface/IpcTypes.js' -import LogController from '../Log/Controller.js' +import LogController, { type Logger } from '../Log/Controller.js' import { publicProcedure, router, toIterable } from '../UI/TRPC.js' import { createOrSanitizeSurfaceHandlerConfig, PanelDefaults } from './Config.js' import { SurfaceGroup, validateGroupConfigValue } from './Group.js' @@ -54,6 +55,7 @@ import { EmulatorRoom, SurfaceIPElgatoEmulator } from './IP/ElgatoEmulator.js' import { SurfaceIPSatellite, type SatelliteDeviceInfo } from './IP/Satellite.js' import { SurfaceOutboundController } from './Outbound.js' import type { SurfacePluginPanel } from './PluginPanel.js' +import { createSurfacesRestApiRouter } from './SurfacesRestApi.js' import type { SurfaceHandlerDependencies, SurfacePanel, UpdateEvents } from './Types.js' /** @@ -413,6 +415,10 @@ export class SurfaceController extends EventEmitter { return handler } + createRestApiRouter(logger: Logger): express.Router { + return createSurfacesRestApiRouter(logger, this, this.#handlerDependencies.pageStore) + } + createTrpcRouter() { const self = this const selfEvents = this as EventEmitter diff --git a/companion/lib/Surface/SurfacesRestApi.ts b/companion/lib/Surface/SurfacesRestApi.ts new file mode 100644 index 0000000000..afd0e5b36f --- /dev/null +++ b/companion/lib/Surface/SurfacesRestApi.ts @@ -0,0 +1,253 @@ +import type { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi' +import Express from 'express' +import z from 'zod' +import type { Logger } from '../Log/Controller.js' +import type { IPageStore } from '../Page/Store.js' +import { RestApiError } from '../Service/RestApi/errors.js' +import { + collectionResponse, + createCollectionSchema, + createSuccessSchema, + errorResponses, + ErrorResponseSchema, + successResponse, +} from '../Service/RestApi/schemas/common.js' +import { + createRestEndpointSpecFactory, + mountRestEndpoint, + registerRestEndpoint, + type RestEndpointSpec, +} from '../Service/RestApi/typedRoute.js' +import type { SurfaceController } from './Controller.js' + +/** Schema for the page a surface is currently showing */ +const SurfacePageSchema = z.object({ + id: z.string().describe('Unique page id.').meta({ example: 'ggmHXCUQ0RRXUwEr8HHtQ' }), + number: z + .number() + .nullable() + .describe('Position of the page in the page list, or null if the page is no longer in the list.') + .meta({ example: 1 }), + name: z.string().nullable().describe('Display name of the page.').meta({ example: 'Main' }), +}) + +/** Schema for the grid size of a surface */ +const SurfaceSizeSchema = z.object({ + rows: z.number().describe('Number of button rows on the surface.').meta({ example: 4 }), + columns: z.number().describe('Number of button columns on the surface.').meta({ example: 8 }), +}) + +const SurfaceResponseExample = { + id: 'streamdeck:1A2B3C4D', + type: 'Elgato Stream Deck XL', + integrationType: 'elgato-stream-deck', + name: 'Front of house', + displayName: 'Front of house (streamdeck:1A2B3C4D)', + isConnected: true, + size: { rows: 4, columns: 8 }, + brightness: 80, + page: { id: 'ggmHXCUQ0RRXUwEr8HHtQ', number: 1, name: 'Main' }, +} + +/** Schema for a surface in API responses — used for both validation and stripping */ +const SurfaceResponseSchema = z + .object({ + id: z.string().describe('Unique surface id.').meta({ example: SurfaceResponseExample.id }), + type: z + .string() + .describe('Model of the surface, as reported by the integration.') + .meta({ example: SurfaceResponseExample.type }), + integrationType: z + .string() + .describe('Integration the surface is connected through.') + .meta({ example: SurfaceResponseExample.integrationType }), + name: z.string().describe('Name given to the surface in Companion.').meta({ example: SurfaceResponseExample.name }), + displayName: z + .string() + .describe('Name shown for the surface in the Companion UI.') + .meta({ example: SurfaceResponseExample.displayName }), + isConnected: z.boolean().describe('Whether the surface is currently connected.').meta({ example: true }), + size: SurfaceSizeSchema.nullable().describe('Button grid size of the surface, if known.'), + brightness: z + .number() + .nullable() + .describe('Brightness of the surface in percent, or null if it is not set in the surface config.') + .meta({ example: 80 }), + page: SurfacePageSchema.nullable().describe( + 'Page the surface is currently showing, or null if it is not showing one.' + ), + }) + .meta({ example: SurfaceResponseExample }) + +/** Schema for partially updating a surface */ +const SurfacePatchBodySchema = z + .object({ + brightness: z + .number() + .int() + .min(0) + .max(100) + .describe('Brightness to apply to the surface, in percent.') + .meta({ example: 50 }), + }) + .strict() + +type SurfaceResponse = z.infer + +const SURFACES_API_BASE_PATH = '/surfaces/v1' +const SURFACES_API_TAGS = ['Surfaces'] + +type SurfacesRestContext = { + logger: Logger + surfaceController: SurfaceController + pageStore: IPageStore +} + +const defineSurfaceEndpointSpec = createRestEndpointSpecFactory() + +/** + * Create the surfaces router for /api/v2/surfaces/v1 + */ +export function createSurfacesRestApiRouter( + logger: Logger, + surfaceController: SurfaceController, + pageStore: IPageStore +): Express.Router { + const surfacesRouter = Express.Router() + const surfacesLogger = logger.child({ source: 'surfaces/v1' }) + + for (const endpointSpec of surfaceEndpointSpecs) { + mountRestEndpoint( + surfacesRouter, + endpointSpec.createEndpoint({ logger: surfacesLogger, surfaceController, pageStore }) + ) + } + + const router = Express.Router() + router.use(SURFACES_API_BASE_PATH, surfacesRouter) + + return router +} + +const surfaceIdParam = z.object({ + surfaceId: z + .string() + .describe('Surface id, as returned by the list surfaces endpoint.') + .meta({ example: SurfaceResponseExample.id }), +}) + +const surfaceEndpointSpecs: RestEndpointSpec[] = [ + defineSurfaceEndpointSpec( + { + method: 'get', + path: '/', + scopes: ['read'], + tags: SURFACES_API_TAGS, + summary: 'List all surfaces', + description: 'Returns all known surfaces, connected or not, with their current state.', + response: { + status: 200, + description: 'List of surfaces', + schema: createCollectionSchema(SurfaceResponseSchema), + }, + examples: { + response: collectionResponse([SurfaceResponseExample], { total: 1, limit: 1, offset: 0 }), + }, + errorResponses, + }, + ({ surfaceController, pageStore }) => { + return () => { + const surfaces = listSurfaces(surfaceController, pageStore) + + return { + body: collectionResponse(surfaces, { total: surfaces.length, limit: surfaces.length, offset: 0 }), + } + } + } + ), + + defineSurfaceEndpointSpec( + { + method: 'patch', + path: '/:surfaceId', + scopes: ['write'], + tags: SURFACES_API_TAGS, + summary: 'Update a surface', + description: 'Update a connected surface. Currently only the brightness can be changed.', + request: { + params: surfaceIdParam, + body: SurfacePatchBodySchema, + }, + response: { + status: 200, + description: 'Updated surface', + schema: createSuccessSchema(SurfaceResponseSchema), + }, + examples: { + body: { brightness: 50 }, + response: successResponse({ ...SurfaceResponseExample, brightness: 50 }), + }, + extraResponses: { + 409: { + description: 'Surface is not connected', + content: { 'application/json': { schema: ErrorResponseSchema } }, + }, + }, + errorResponses, + }, + ({ logger, surfaceController, pageStore }) => { + return ({ params, body }) => { + const { surfaceId } = params + const { brightness } = body + + const surface = listSurfaces(surfaceController, pageStore).find((surface) => surface.id === surfaceId) + if (!surface) throw RestApiError.notFound('Surface not found') + if (!surface.isConnected) throw RestApiError.conflict('Surface is not connected') + + surfaceController.setDeviceBrightness(surfaceId, brightness) + + logger.info(`Set brightness of surface "${surface.displayName}" (${surfaceId}) to ${brightness}`) + return { body: successResponse({ ...surface, brightness }) } + } + } + ), +] + +/** + * Build the validated SurfaceResponse for every known surface. + */ +function listSurfaces(surfaceController: SurfaceController, pageStore: IPageStore): SurfaceResponse[] { + return surfaceController.getDevicesList().flatMap((group) => { + // All surfaces in a group share the same page + const page = buildSurfacePage(surfaceController, pageStore, group.id) + + return group.surfaces.map((surface) => + SurfaceResponseSchema.parse({ ...surface, brightness: surface.brightness ?? null, page }) + ) + }) +} + +/** + * Resolve the page a surface group is currently showing + */ +function buildSurfacePage( + surfaceController: SurfaceController, + pageStore: IPageStore, + groupId: string +): SurfaceResponse['page'] { + const pageId = surfaceController.devicePageGet(groupId) + if (!pageId) return null + + const number = pageStore.getPageNumber(pageId) + return { + id: pageId, + number, + name: (number !== null ? pageStore.getPageName(number) : undefined) ?? null, + } +} + +export function registerSurfacePaths(registry: OpenAPIRegistry): void { + for (const endpointSpec of surfaceEndpointSpecs) { + registerRestEndpoint(registry, SURFACES_API_BASE_PATH, endpointSpec.contract) + } +} diff --git a/companion/test/Instance/Connection/ConnectionsRestApi.test.ts b/companion/test/Instance/Connection/ConnectionsRestApi.test.ts index d981300cab..d17d4d4d1f 100644 --- a/companion/test/Instance/Connection/ConnectionsRestApi.test.ts +++ b/companion/test/Instance/Connection/ConnectionsRestApi.test.ts @@ -12,6 +12,7 @@ import type { InstanceConfigStore } from '../../../lib/Instance/ConfigStore.js' import { ConnectionCreateBodySchema } from '../../../lib/Instance/Connection/ConnectionsRestApi.js' import type { InstanceController } from '../../../lib/Instance/Controller.js' import { createInstanceRestApiRouter } from '../../../lib/Instance/RestApi.js' +import type { Logger } from '../../../lib/Log/Controller.js' import type { Registry } from '../../../lib/Registry.js' import { REST_API_BASE_PATH } from '../../../lib/Service/RestApi/constants.js' import { createRestApiRouter } from '../../../lib/Service/RestApi/RestApiRouter.js' @@ -50,9 +51,12 @@ type TestService = { function createTestRegistry(instanceController: InstanceController, configStore: InstanceConfigStore): Registry { return { instance: { - createRestApiRouter: (logger) => createInstanceRestApiRouter(logger, instanceController, configStore), + createRestApiRouter: (logger: Logger) => createInstanceRestApiRouter(logger, instanceController, configStore), }, - } as Registry + surfaces: { + createRestApiRouter: () => express.Router(), + }, + } as unknown as Registry } function createService(): TestService { diff --git a/companion/test/Surface/SurfacesRestApi.test.ts b/companion/test/Surface/SurfacesRestApi.test.ts new file mode 100644 index 0000000000..60a4719792 --- /dev/null +++ b/companion/test/Surface/SurfacesRestApi.test.ts @@ -0,0 +1,239 @@ +import express from 'express' +import supertest from 'supertest' +import { describe, expect, test } from 'vitest' +import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended' +import type { ClientDevicesListItem, ClientSurfaceItem } from '../../../shared-lib/lib/Model/Surfaces.js' +import type { Logger } from '../../lib/Log/Controller.js' +import type { IPageStore } from '../../lib/Page/Store.js' +import type { Registry } from '../../lib/Registry.js' +import { REST_API_BASE_PATH } from '../../lib/Service/RestApi/constants.js' +import { createRestApiRouter } from '../../lib/Service/RestApi/RestApiRouter.js' +import { RestApiTokenStoreMemory } from '../../lib/Service/RestApi/RestApiTokenStore.js' +import type { SurfaceController } from '../../lib/Surface/Controller.js' +import { createSurfacesRestApiRouter } from '../../lib/Surface/SurfacesRestApi.js' + +const mockOptions = { + fallbackMockImplementation: () => { + throw new Error('not mocked') + }, +} + +const mockAppInfo = { + appVersion: '5.0.0-test', +} + +const tokens = { + read: 'cpn_read', + write: 'cpn_write', +} + +const SURFACES_PATH = `${REST_API_BASE_PATH}/surfaces/v1` + +type TestService = { + app: express.Express + surfaceController: DeepMockProxy + pageStore: DeepMockProxy +} + +function createTestRegistry(surfaceController: SurfaceController, pageStore: IPageStore): Registry { + return { + instance: { + createRestApiRouter: () => express.Router(), + }, + surfaces: { + createRestApiRouter: (logger: Logger) => createSurfacesRestApiRouter(logger, surfaceController, pageStore), + }, + } as unknown as Registry +} + +function createService(): TestService { + const surfaceController = mockDeep(mockOptions) + const pageStore = mockDeep(mockOptions) + + // Every surface response resolves the page of its group + surfaceController.devicePageGet.mockReturnValue('page-id-1') + pageStore.getPageNumber.mockReturnValue(1) + pageStore.getPageName.mockReturnValue('Main') + const restApiRouter = createRestApiRouter( + createTestRegistry(surfaceController, pageStore), + new RestApiTokenStoreMemory(), + mockAppInfo + ) + + const app = express() + app.use(express.json()) + app.use(REST_API_BASE_PATH, restApiRouter) + + return { app, surfaceController, pageStore } +} + +function createSurface(id: string, props: Partial): ClientSurfaceItem { + return { + id, + type: 'Streamdeck XL', + integrationType: 'elgato-streamdeck', + name: 'Front of house', + configFields: [], + isConnected: true, + displayName: `Front of house (${id})`, + location: null, + locked: false, + enabled: true, + canChangeEnabled: true, + hasFirmwareUpdates: null, + size: { rows: 4, columns: 8 }, + rotation: null, + brightness: 80, + offset: { rows: 0, columns: 0 }, + ...props, + } +} + +function createDevicesList(): ClientDevicesListItem[] { + return [ + { + id: 'group-1', + index: 0, + displayName: 'Group 1', + isAutoGroup: false, + surfaces: [createSurface('surface-1', {}), createSurface('surface-2', { brightness: null })], + }, + { + id: 'surface-3', + index: 1, + displayName: 'Offline surface', + isAutoGroup: true, + surfaces: [createSurface('surface-3', { isConnected: false, size: null })], + }, + ] +} + +describe('Surfaces REST API', () => { + describe('GET /surfaces', () => { + test('returns a flat list of the surfaces of all groups', async () => { + const service = createService() + service.surfaceController.getDevicesList.mockReturnValue(createDevicesList()) + + const res = await supertest(service.app).get(SURFACES_PATH).set('Authorization', `Bearer ${tokens.read}`).send() + + expect(res.status).toBe(200) + expect(res.body.meta).toEqual({ total: 3, limit: 3, offset: 0 }) + expect(res.body.data).toEqual([ + { + id: 'surface-1', + type: 'Streamdeck XL', + integrationType: 'elgato-streamdeck', + name: 'Front of house', + displayName: 'Front of house (surface-1)', + isConnected: true, + size: { rows: 4, columns: 8 }, + brightness: 80, + page: { id: 'page-id-1', number: 1, name: 'Main' }, + }, + expect.objectContaining({ id: 'surface-2', brightness: null }), + expect.objectContaining({ id: 'surface-3', isConnected: false, size: null }), + ]) + }) + + test('returns a null page when the group is not on a page', async () => { + const service = createService() + service.surfaceController.getDevicesList.mockReturnValue(createDevicesList()) + service.surfaceController.devicePageGet.mockReturnValue(undefined) + + const res = await supertest(service.app).get(SURFACES_PATH).set('Authorization', `Bearer ${tokens.read}`).send() + + expect(res.status).toBe(200) + expect(res.body.data[0].page).toBeNull() + }) + + test('returns a null page name when the page is no longer in the page list', async () => { + const service = createService() + service.surfaceController.getDevicesList.mockReturnValue(createDevicesList()) + service.pageStore.getPageNumber.mockReturnValue(null) + + const res = await supertest(service.app).get(SURFACES_PATH).set('Authorization', `Bearer ${tokens.read}`).send() + + expect(res.status).toBe(200) + expect(res.body.data[0].page).toEqual({ id: 'page-id-1', number: null, name: null }) + }) + + test('returns 401 without a token', async () => { + const service = createService() + + const res = await supertest(service.app).get(SURFACES_PATH).send() + + expect(res.status).toBe(401) + expect(res.body.error.code).toBe('UNAUTHORIZED') + }) + }) + + describe('PATCH /surfaces/:surfaceId', () => { + test('sets the brightness and returns the updated surface', async () => { + const service = createService() + service.surfaceController.getDevicesList.mockReturnValue(createDevicesList()) + service.surfaceController.setDeviceBrightness.mockReturnValue(undefined) + + const res = await supertest(service.app) + .patch(`${SURFACES_PATH}/surface-1`) + .set('Authorization', `Bearer ${tokens.write}`) + .send({ brightness: 50 }) + + expect(res.status).toBe(200) + expect(service.surfaceController.setDeviceBrightness).toHaveBeenCalledWith('surface-1', 50) + expect(res.body.data).toEqual(expect.objectContaining({ id: 'surface-1', brightness: 50 })) + }) + + test('returns 404 for an unknown surface', async () => { + const service = createService() + service.surfaceController.getDevicesList.mockReturnValue(createDevicesList()) + + const res = await supertest(service.app) + .patch(`${SURFACES_PATH}/surface-9`) + .set('Authorization', `Bearer ${tokens.write}`) + .send({ brightness: 50 }) + + expect(res.status).toBe(404) + expect(res.body.error.code).toBe('NOT_FOUND') + }) + + test('returns 409 for a surface which is not connected', async () => { + const service = createService() + service.surfaceController.getDevicesList.mockReturnValue(createDevicesList()) + + const res = await supertest(service.app) + .patch(`${SURFACES_PATH}/surface-3`) + .set('Authorization', `Bearer ${tokens.write}`) + .send({ brightness: 50 }) + + expect(res.status).toBe(409) + expect(res.body.error.code).toBe('CONFLICT') + }) + + test.each([{ brightness: 101 }, { brightness: -1 }, { brightness: 50.5 }, { brightness: '50' }, {}])( + 'returns 400 for the invalid body %j', + async (body) => { + const service = createService() + + const res = await supertest(service.app) + .patch(`${SURFACES_PATH}/surface-1`) + .set('Authorization', `Bearer ${tokens.write}`) + .send(body) + + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('BAD_REQUEST') + } + ) + + test('returns 403 for a read only token', async () => { + const service = createService() + + const res = await supertest(service.app) + .patch(`${SURFACES_PATH}/surface-1`) + .set('Authorization', `Bearer ${tokens.read}`) + .send({ brightness: 50 }) + + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + }) +})