diff --git a/go.mod b/go.mod index 58cfe9d9f0..aae3b82098 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/blang/semver/v4 v4.0.0 github.com/eclipse/paho.mqtt.golang v1.5.1 github.com/edgexfoundry/go-mod-bootstrap/v4 v4.1.0-dev.45 - github.com/edgexfoundry/go-mod-core-contracts/v4 v4.1.0-dev.21 + github.com/edgexfoundry/go-mod-core-contracts/v4 v4.1.0-dev.22 github.com/edgexfoundry/go-mod-messaging/v4 v4.1.0-dev.18 github.com/edgexfoundry/go-mod-secrets/v4 v4.1.0-dev.7 github.com/fxamacker/cbor/v2 v2.9.0 diff --git a/go.sum b/go.sum index b7775d15bd..cca5d764a0 100644 --- a/go.sum +++ b/go.sum @@ -76,8 +76,8 @@ github.com/edgexfoundry/go-mod-bootstrap/v4 v4.1.0-dev.45 h1:e8zhpWhjPfDypTmPxgM github.com/edgexfoundry/go-mod-bootstrap/v4 v4.1.0-dev.45/go.mod h1:E9iUXkxMdTMXxyAN/MAr/srf8+ZbmtV+mVJvhW6a//k= github.com/edgexfoundry/go-mod-configuration/v4 v4.1.0-dev.17 h1:TttwsEqLppEQQz4scPbEdzMtsHC8vj1djd2sgZLfny8= github.com/edgexfoundry/go-mod-configuration/v4 v4.1.0-dev.17/go.mod h1:IlEPPn0CZX1mDBRX8E6nr7BM/MVxMZV5z9zSTo6fUgo= -github.com/edgexfoundry/go-mod-core-contracts/v4 v4.1.0-dev.21 h1:gh+CoXbkXa2E3favumU513BYnB8U3ubW2zBUJpWNSwU= -github.com/edgexfoundry/go-mod-core-contracts/v4 v4.1.0-dev.21/go.mod h1:jDm9E4z9svXErYAxr0oigmVV50wmIoHaveOlP7FBkHQ= +github.com/edgexfoundry/go-mod-core-contracts/v4 v4.1.0-dev.22 h1:rV4aHYBoLvlyy9XHBDSC+cXbhiyRotF0RqZVI46Rd7I= +github.com/edgexfoundry/go-mod-core-contracts/v4 v4.1.0-dev.22/go.mod h1:jDm9E4z9svXErYAxr0oigmVV50wmIoHaveOlP7FBkHQ= github.com/edgexfoundry/go-mod-messaging/v4 v4.1.0-dev.18 h1:iLlwJBZewKcoL+Ao4HQtcVrsfTtCEoGZRiNQjPGucPo= github.com/edgexfoundry/go-mod-messaging/v4 v4.1.0-dev.18/go.mod h1:PcyJ06iPZfWH88+4Mmk8IJI2pDDclwdwI883wKoKocM= github.com/edgexfoundry/go-mod-registry/v4 v4.1.0-dev.8 h1:swAEoWn8rr/NXcsBaCxoY+lWSiDUfZUmTyBsi9aM/7o= diff --git a/internal/core/metadata/application/deviceprofile.go b/internal/core/metadata/application/deviceprofile.go index 6181c561a5..b25d11c842 100644 --- a/internal/core/metadata/application/deviceprofile.go +++ b/internal/core/metadata/application/deviceprofile.go @@ -313,6 +313,33 @@ func AllDeviceProfileBasicInfos(offset int, limit int, labels []string, dic *di. return deviceProfileBasicInfos, totalCount, nil } +func PatchDeviceProfileTags(profileName string, dto dtos.UpdateDeviceProfileTags, ctx context.Context, dic *di.Container) errors.EdgeX { + dbClient := container.DBClientFrom(dic.Get) + lc := bootstrapContainer.LoggingClientFrom(dic.Get) + + deviceProfile, err := dbClient.DeviceProfileByName(profileName) + if err != nil { + return errors.NewCommonEdgeXWrapper(err) + } + + requests.ReplaceDeviceProfileModelTagsWithDTO(&deviceProfile, dto) + + err = dbClient.UpdateDeviceProfile(deviceProfile) + if err != nil { + return errors.NewCommonEdgeXWrapper(err) + } + + lc.Debugf( + "DeviceProfile device resources/commands tags patched on DB successfully. Correlation-ID: %s ", + correlation.FromContext(ctx), + ) + + profileDTO := dtos.FromDeviceProfileModelToDTO(deviceProfile) + go publishUpdateDeviceProfileSystemEvent(profileDTO, ctx, dic) + + return nil +} + func deviceProfileByDTO(dbClient interfaces.DBClient, dto dtos.UpdateDeviceProfileBasicInfo) (deviceProfile models.DeviceProfile, err errors.EdgeX) { // The ID or Name is required by DTO and the DTO also accepts empty string ID if the Name is provided if dto.Id != nil && *dto.Id != "" { diff --git a/internal/core/metadata/controller/http/deviceprofile.go b/internal/core/metadata/controller/http/deviceprofile.go index f02999a5ee..54e027db72 100644 --- a/internal/core/metadata/controller/http/deviceprofile.go +++ b/internal/core/metadata/controller/http/deviceprofile.go @@ -404,3 +404,34 @@ func (dc *DeviceProfileController) AllDeviceProfileBasicInfos(c echo.Context) er utils.WriteHttpHeader(w, ctx, http.StatusOK) return pkg.EncodeAndWriteResponse(response, w, lc) } + +func (dc *DeviceProfileController) PatchDeviceProfileTags(c echo.Context) error { + r := c.Request() + w := c.Response() + if r.Body != nil { + defer func() { _ = r.Body.Close() }() + } + + lc := container.LoggingClientFrom(dc.dic.Get) + + ctx := r.Context() + + // URL parameters + name := c.Param(common.Name) + + var reqDTO requestDTO.DeviceProfileTagsRequest + err := dc.jsonDtoReader.Read(r.Body, &reqDTO) + if err != nil { + return utils.WriteErrorResponse(w, ctx, lc, err, "") + } + + reqId := reqDTO.RequestId + err = application.PatchDeviceProfileTags(name, reqDTO.UpdateDeviceProfileTags, ctx, dc.dic) + if err != nil { + return utils.WriteErrorResponse(w, ctx, lc, err, reqId) + } + + response := commonDTO.NewBaseResponse(reqId, "", http.StatusOK) + utils.WriteHttpHeader(w, ctx, http.StatusOK) + return pkg.EncodeAndWriteResponse(response, w, lc) +} diff --git a/internal/core/metadata/controller/http/deviceprofile_test.go b/internal/core/metadata/controller/http/deviceprofile_test.go index 65f657d50f..3df28bbc00 100644 --- a/internal/core/metadata/controller/http/deviceprofile_test.go +++ b/internal/core/metadata/controller/http/deviceprofile_test.go @@ -1605,3 +1605,108 @@ func TestAllDeviceProfileBasicInfos(t *testing.T) { }) } } + +func TestPatchDeviceProfileTags(t *testing.T) { + deviceProfile := dtos.ToDeviceProfileModel(buildTestDeviceProfileRequest().Profile) + notFoundName := "notFoundName" + expectedRequestId := ExampleUUID + updateTags := map[string]any{"TestTagKey": "NewTestTagValue", "TestTagKey2": "TestTagValue2"} + testReq := requests.DeviceProfileTagsRequest{ + BaseRequest: commonDTO.BaseRequest{ + Versionable: commonDTO.NewVersionable(), + RequestId: ExampleUUID, + }, + UpdateDeviceProfileTags: dtos.UpdateDeviceProfileTags{ + DeviceResources: []dtos.UpdateTags{{Name: TestDeviceResourceName, Tags: updateTags}}, + DeviceCommands: []dtos.UpdateTags{{Name: TestDeviceCommandName, Tags: updateTags}}, + }, + } + + valid := testReq + noRequestId := valid + noRequestId.RequestId = "" + + noDRName := testReq + noDRName.DeviceResources = []dtos.UpdateTags{{Tags: updateTags}} + emptyDRName := testReq + emptyDRName.DeviceResources = []dtos.UpdateTags{{Name: " ", Tags: updateTags}} + noDRTags := testReq + noDRTags.DeviceResources = []dtos.UpdateTags{{Name: TestDeviceResourceName}} + emptyDRTags := testReq + emptyDRTags.DeviceResources = []dtos.UpdateTags{{Name: TestDeviceCommandName, Tags: map[string]any{}}} + + noDCName := testReq + noDCName.DeviceCommands = []dtos.UpdateTags{{Tags: updateTags}} + emptyDCName := testReq + emptyDCName.DeviceCommands = []dtos.UpdateTags{{Name: " ", Tags: updateTags}} + noDCTags := testReq + noDCTags.DeviceCommands = []dtos.UpdateTags{{Name: TestDeviceCommandName}} + + emptyDCTags := testReq + emptyDCTags.DeviceCommands = []dtos.UpdateTags{{Name: TestDeviceCommandName, Tags: map[string]any{}}} + + dic := mockDic() + dbClientMock := &mocks.DBClient{} + dbClientMock.On("DeviceProfileByName", deviceProfile.Name).Return(deviceProfile, nil) + dbClientMock.On("DeviceProfileByName", notFoundName).Return(deviceProfile, errors.NewCommonEdgeX(errors.KindEntityDoesNotExist, "not found", nil)) + dbClientMock.On("DevicesByProfileName", 0, -1, deviceProfile.Name).Return([]models.Device{{ServiceName: testDeviceServiceName}}, nil) + dbClientMock.On("DeviceCountByProfileName", deviceProfile.Name).Return(int64(1), nil) + dbClientMock.On("UpdateDeviceProfile", mock.Anything).Return(nil) + dic.Update(di.ServiceConstructorMap{ + container.DBClientInterfaceName: func(get di.Get) interface{} { + return dbClientMock + }, + }) + + controller := NewDeviceProfileController(dic) + require.NotNil(t, controller) + + tests := []struct { + name string + deviceProfileName string + request requests.DeviceProfileTagsRequest + expectedStatusCode int + }{ + {"valid", deviceProfile.Name, valid, http.StatusOK}, + {"valid - no request id", deviceProfile.Name, noRequestId, http.StatusOK}, + {"invalid - device profile not found", notFoundName, valid, http.StatusNotFound}, + {"invalid - no device resource name", deviceProfile.Name, noDRName, http.StatusBadRequest}, + {"invalid - empty device resource name", deviceProfile.Name, emptyDRName, http.StatusBadRequest}, + {"invalid - no device resource tags", deviceProfile.Name, noDRTags, http.StatusBadRequest}, + {"invalid - empty device resource tags", deviceProfile.Name, emptyDRTags, http.StatusBadRequest}, + {"invalid - no device command name", deviceProfile.Name, noDCName, http.StatusBadRequest}, + {"invalid - empty device command name", deviceProfile.Name, emptyDCName, http.StatusBadRequest}, + {"invalid - no device command tags", deviceProfile.Name, noDRTags, http.StatusBadRequest}, + {"invalid - empty device command tags", deviceProfile.Name, emptyDCTags, http.StatusBadRequest}, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + e := echo.New() + jsonData, err := json.Marshal(testCase.request) + require.NoError(t, err) + + reader := strings.NewReader(string(jsonData)) + req, err := http.NewRequest(http.MethodPatch, common.ApiDeviceProfileTagsByNameRoute, reader) + require.NoError(t, err) + + // Act + recorder := httptest.NewRecorder() + c := e.NewContext(req, recorder) + c.SetParamNames(common.Name) + c.SetParamValues(testCase.deviceProfileName) + err = controller.PatchDeviceProfileTags(c) + require.NoError(t, err) + + var res commonDTO.BaseResponse + err = json.Unmarshal(recorder.Body.Bytes(), &res) + require.NoError(t, err) + + assert.NotEmpty(t, recorder.Body.String(), "Message is empty") + assert.Equal(t, common.ApiVersion, res.ApiVersion, "API Version not as expected") + assert.Equal(t, testCase.expectedStatusCode, recorder.Result().StatusCode, "HTTP status code not as expected") + if res.RequestId != "" { + assert.Equal(t, expectedRequestId, res.RequestId, "RequestID not as expected") + } + }) + } +} diff --git a/internal/core/metadata/router.go b/internal/core/metadata/router.go index cf041d88c0..72b9342d8f 100644 --- a/internal/core/metadata/router.go +++ b/internal/core/metadata/router.go @@ -43,6 +43,7 @@ func LoadRestRoutes(r *echo.Echo, dic *di.Container, serviceName string) { r.GET(common.ApiDeviceProfileByManufacturerAndModelRoute, dc.DeviceProfilesByManufacturerAndModel, authenticationHook) r.PATCH(common.ApiDeviceProfileBasicInfoRoute, dc.PatchDeviceProfileBasicInfo, authenticationHook) r.GET(common.ApiAllDeviceProfileBasicInfoRoute, dc.AllDeviceProfileBasicInfos, authenticationHook) + r.PATCH(common.ApiDeviceProfileTagsByNameRoute, dc.PatchDeviceProfileTags, authenticationHook) // Device Resource dr := metadataController.NewDeviceResourceController(dic) diff --git a/openapi/core-metadata.yaml b/openapi/core-metadata.yaml index b23eb029f1..66be35a490 100644 --- a/openapi/core-metadata.yaml +++ b/openapi/core-metadata.yaml @@ -304,7 +304,6 @@ components: properties: type: object description: A map of properties required to address the given device - DeviceProfileBasicInfo: description: "A profile basic information" type: object @@ -336,6 +335,32 @@ components: $ref: '#/components/schemas/DeviceProfileBasicInfo' required: - profileName + UpdateTags: + type: object + properties: + name: + type: string + description: device resource or device command name + tags: + type: object + description: A map of tags to add or update + required: + - name + - tags + DeviceProfileTagsRequest: + description: "Add/Update tags of device resources/device commands in an existing profile" + type: object + allOf: + - $ref: '#/components/schemas/BaseRequest' + properties: + deviceResources: + type: array + items: + $ref: '#/components/schemas/UpdateTags' + deviceCommands: + type: array + items: + $ref: '#/components/schemas/UpdateTags' MultiDeviceProfileBasicInfosResponse: allOf: - $ref: '#/components/schemas/BaseWithTotalCountResponse' @@ -446,7 +471,7 @@ components: type: boolean description: "Indicate the visibility of the DeviceResource via a CoreCommand." tags: - type: string + type: object description: "Tags for adding additional information on reading level" properties: $ref: '#/components/schemas/ResourceProperties' @@ -853,7 +878,7 @@ components: name: type: string tags: - type: string + type: object description: "Tags for adding additional information on event level" isHidden: type: boolean @@ -2671,6 +2696,73 @@ paths: examples: 500Example: $ref: '#/components/examples/500Example' + '/deviceprofile/name/{name}/tags': + parameters: + - $ref: '#/components/parameters/correlatedRequestHeader' + - name: name + in: path + required: true + schema: + type: string + description: "The unique name of a device profile" + patch: + summary: "Allows adding or updating the tags field for device resources or device commands within an existing device profile. Removing existing tags is not supported." + requestBody: + required: true + content: + application/json: + schema: + type: object + $ref: '#/components/schemas/DeviceProfileTagsRequest' + example: + apiVersion: "v3" + requestId: "2463bff9-aa53-4bc4-bebf-42fe81146ea8" + deviceResources: + - name: "Float32" + tags: + tag1: "field1Value" + deviceCommands: + - name: "Float32" + tags: + tag2: + field3: "field3Value" + responses: + '200': + description: "Update successful" + headers: + X-Correlation-ID: + $ref: '#/components/headers/correlatedResponseHeader' + content: + application/json: + schema: + $ref: '#/components/schemas/BaseResponse' + examples: + 200Example: + $ref: '#/components/examples/200Example' + '400': + description: "Request is in an invalid state" + headers: + X-Correlation-ID: + $ref: '#/components/headers/correlatedResponseHeader' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + 400Example: + $ref: '#/components/examples/400Example' + '500': + description: An unexpected error occurred on the server + headers: + X-Correlation-ID: + $ref: '#/components/headers/correlatedResponseHeader' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + 500Example: + $ref: '#/components/examples/500Example' '/deviceprofile/basicinfo': parameters: - $ref: '#/components/parameters/correlatedRequestHeader'