From c75abef4c313bbe33ad833189dfcc6fdc50fc15d Mon Sep 17 00:00:00 2001 From: Juiced66 <80784430+Juiced66@users.noreply.github.com> Date: Fri, 19 Jul 2024 15:34:37 +0200 Subject: [PATCH] feat: upsert devices (#355) * feat: upsert devices * fixup! feat: upsert devices * fixup! feat: upsert devices --- doc/2/controllers/assets/upsert/index.md | 6 +- doc/2/controllers/devices/upsert/index.md | 69 ++++++++++ lib/modules/device/DeviceService.ts | 69 ++++++++++ lib/modules/device/DevicesController.ts | 24 ++++ lib/modules/device/types/DeviceApi.ts | 18 +++ tests/scenario/migrated/device-upsert.test.ts | 128 ++++++++++++++++++ 6 files changed, 312 insertions(+), 2 deletions(-) create mode 100644 doc/2/controllers/devices/upsert/index.md create mode 100644 tests/scenario/migrated/device-upsert.test.ts diff --git a/doc/2/controllers/assets/upsert/index.md b/doc/2/controllers/assets/upsert/index.md index dfead7f7..aed9a229 100644 --- a/doc/2/controllers/assets/upsert/index.md +++ b/doc/2/controllers/assets/upsert/index.md @@ -26,7 +26,8 @@ Method: POST "controller": "device-manager/assets", "action": "upsert", "engineId": "", - "_id": "", + "reference": "", + "model": "", "body": { "metadata": { "": "" @@ -40,7 +41,8 @@ Method: POST ## Arguments - `engineId`: Engine ID -- `_id`: Asset ID +- `reference` : asset reference +- `model`: asset model ## Body properties diff --git a/doc/2/controllers/devices/upsert/index.md b/doc/2/controllers/devices/upsert/index.md new file mode 100644 index 00000000..375607de --- /dev/null +++ b/doc/2/controllers/devices/upsert/index.md @@ -0,0 +1,69 @@ +--- +code: true +type: page +title: upsert +description: Update or Create a device +--- + +# upsert + +Update or Create a device. +The Upsert operation allows you to create a new device or update an existing one if it already exists. This operation is useful when you want to ensure that an device is either created or updated in a single request. + +## Query Syntax + +### HTTP + +```http +URL: http://kuzzle:7512/_/device-manager/:engineId/devices/:_id +Method: POST +``` + +## Other protocols + +```js +{ + "controller": "device-manager/devices", + "action": "upsert", + "engineId": "", + "reference": "", + "model": "", + "body": { + "metadata": { + "": "" + } + } +} +``` + +--- + +## Arguments + +- `engineId`: Engine ID +- `reference` : device reference +- `model`: device model + +## Body properties + +- `metadata`: Object containing metadata + +--- + +## Response + +```js +{ + "status": 200, + "error": null, + "controller": "device-manager/devices", + "action": "update", + "requestId": "", + "result": { + "_id": "", + "_source": { + // device content + }, + } +} +``` diff --git a/lib/modules/device/DeviceService.ts b/lib/modules/device/DeviceService.ts index 7a486f4f..7328998c 100644 --- a/lib/modules/device/DeviceService.ts +++ b/lib/modules/device/DeviceService.ts @@ -178,6 +178,75 @@ export class DeviceService extends BaseService { ); } + /** + * Update or Create an device metadata + */ + public async upsert( + engineId: string, + model: string, + reference: string, + metadata: Metadata, + request: KuzzleRequest, + ): Promise> { + const deviceId = `${model}-${reference}`; + return lock(`device:${engineId}:${deviceId}`, async () => { + const adminIndexDevice = await this.get( + this.config.adminIndex, + deviceId, + request, + ).catch(() => null); + + if (!adminIndexDevice) { + return this.create(model, reference, metadata, request); + } + + if ( + adminIndexDevice._source.engineId && + adminIndexDevice._source.engineId !== engineId + ) { + throw new BadRequestError( + `Device "${adminIndexDevice._id}" already exists on another engine. Abort`, + ); + } + + const engineDevice = await this.get(engineId, deviceId, request).catch( + () => null, + ); + + if (!engineDevice) { + await this.attachEngine(engineId, deviceId, request); + } + + const updatedPayload = await this.app.trigger( + "device-manager:device:update:before", + { device: adminIndexDevice, metadata }, + ); + + const updatedDevice = await this.updateDocument( + request, + { + _id: deviceId, + _source: { metadata: updatedPayload.metadata }, + }, + { + collection: InternalCollection.DEVICES, + engineId, + }, + { source: true }, + ); + + await this.app.trigger( + "device-manager:device:update:after", + { + device: updatedDevice, + metadata: updatedPayload.metadata, + }, + ); + + return updatedDevice; + }); + } + public async update( engineId: string, deviceId: string, diff --git a/lib/modules/device/DevicesController.ts b/lib/modules/device/DevicesController.ts index 269440ef..7c21c77a 100644 --- a/lib/modules/device/DevicesController.ts +++ b/lib/modules/device/DevicesController.ts @@ -26,6 +26,7 @@ import { ApiDeviceUnlinkAssetResult, ApiDeviceUpdateResult, ApiDeviceGetMeasuresResult, + ApiDeviceUpsertResult, } from "./types/DeviceApi"; export class DevicesController { @@ -56,6 +57,12 @@ export class DevicesController { { path: "device-manager/:engineId/devices/:_id", verb: "put" }, ], }, + upsert: { + handler: this.upsert.bind(this), + http: [ + { path: "device-manager/:engineId/devices/:_id", verb: "post" }, + ], + }, search: { handler: this.search.bind(this), http: [ @@ -205,6 +212,23 @@ export class DevicesController { ); } + async upsert(request: KuzzleRequest): Promise { + const engineId = request.getString("engineId"); + const model = request.getBodyString("model"); + const reference = request.getBodyString("reference"); + const metadata = request.getBodyObject("metadata"); + + const upsertDevice = await this.deviceService.upsert( + engineId, + model, + reference, + metadata, + request, + ); + + return DeviceSerializer.serialize(upsertDevice); + } + /** * Create and provision a new device */ diff --git a/lib/modules/device/types/DeviceApi.ts b/lib/modules/device/types/DeviceApi.ts index cbb9b615..d6de0df0 100644 --- a/lib/modules/device/types/DeviceApi.ts +++ b/lib/modules/device/types/DeviceApi.ts @@ -32,6 +32,24 @@ export interface ApiDeviceUpdateRequest extends DevicesControllerRequest { } export type ApiDeviceUpdateResult = KDocument; +export interface ApiDeviceUpsertRequest extends DevicesControllerRequest { + action: "upsert"; + + _id: string; + + refresh?: string; + + body: { + model: string; + + reference: string; + + metadata: Metadata; + }; +} + +export type ApiDeviceUpsertResult = KDocument; + export interface ApiDeviceCreateRequest extends DevicesControllerRequest { action: "create"; diff --git a/tests/scenario/migrated/device-upsert.test.ts b/tests/scenario/migrated/device-upsert.test.ts new file mode 100644 index 00000000..21e7aba3 --- /dev/null +++ b/tests/scenario/migrated/device-upsert.test.ts @@ -0,0 +1,128 @@ +import { beforeEachTruncateCollections } from "../../hooks/collections"; +import { beforeAllCreateEngines } from "../../hooks/engines"; +import { beforeEachLoadFixtures } from "../../hooks/fixtures"; + +import { useSdk } from "../../helpers"; + +jest.setTimeout(10000); + +describe("features/Device/Controller/upsert", () => { + const sdk = useSdk(); + + beforeAll(async () => { + await sdk.connect(); + await beforeAllCreateEngines(sdk); + }); + + beforeEach(async () => { + await beforeEachTruncateCollections(sdk); + await beforeEachLoadFixtures(sdk); + }); + + afterAll(async () => { + sdk.disconnect(); + }); +it("Upsert device", async () => { + const response = await sdk.query({ + controller: "device-manager/devices", + action: "upsert", + engineId: "engine-kuzzle", + _id: "DummyTemp-detached1", + body: { + model: "DummyTemp", + reference: "detached1", + metadata: { color: 'red' }, + }, + }); + + expect(response).toBeDefined(); + expect(response.result).toBeDefined(); + expect(response.result._id).toEqual("DummyTemp-detached1"); + expect(response.result._source.model).toEqual("DummyTemp"); + expect(response.result._source.reference).toEqual("detached1"); + expect(response.result._source.metadata).toEqual({ color: 'red' }); +}); + + it("Upsert device - update existing device", async () => { + // create device + await sdk.query({ + controller: "device-manager/devices", + action: "upsert", + engineId: "engine-kuzzle", + _id: "DummyTemp-detached1", + body: { + model: "DummyTemp", + reference: "detached1", + metadata: { color: 'blue' }, + }, + }); + + // update device + const response = await sdk.query({ + controller: "device-manager/devices", + action: "upsert", + engineId: "engine-kuzzle", + _id: "DummyTemp-detached1", + body: { + model: "DummyTemp", + reference: "detached1", + metadata: { color: 'red' }, + }, + }); + + expect(response).toBeDefined(); + expect(response.result).toBeDefined(); + expect(response.result._id).toEqual("DummyTemp-detached1"); + expect(response.result._source.model).toEqual("DummyTemp"); + expect(response.result._source.reference).toEqual("detached1"); + expect(response.result._source.metadata).toEqual({ color: 'red' }); + }) + + it('Throws if upsert has no engine id', async () => { + + // update asset + let promise + promise = sdk.query({ + controller: "device-manager/devices", + action: "upsert", + _id: "DummyTemp-detached1", + body: { + model: "DummyTemp", + reference: "detached1", + metadata: { color: 'red' }, + }, + }); + + await expect(promise).rejects.toThrow('Missing argument "engineId"'); + }) + + it('Throws if upsert is on another engine id', async () => { + // create device + await sdk.query({ + controller: "device-manager/devices", + action: "upsert", + _id: "DummyTemp-detached1", + engineId: "engine-kuzzle", + body: { + model: "DummyTemp", + reference: "detached1", + metadata: { color: 'blue' }, + }, + }); + // update device + let promise + promise = sdk.query({ + controller: "device-manager/devices", + action: "upsert", + engineId: "engine-ayse", + _id: "DummyTemp-detached1", + body: { + model: "DummyTemp", + reference: "detached1", + metadata: { color: 'red' }, + }, + }); + + await expect(promise).rejects.toThrow('Device "DummyTemp-detached1" already exists on another engine. Abort'); + }) +}); \ No newline at end of file