Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,17 @@
import org.openremote.model.asset.Asset;
import org.openremote.model.asset.AssetResource;
import org.openremote.model.asset.AssetTree;
import org.openremote.model.asset.AttributeConfigurationDocument;
import org.openremote.model.asset.AttributeConfigurationImportFailure;
import org.openremote.model.asset.AttributeConfigurationImportPreview;
import org.openremote.model.asset.AttributeConfigurationImportRequest;
import org.openremote.model.asset.UserAssetLink;
import org.openremote.model.attribute.*;
import org.openremote.model.http.RequestParams;
import org.openremote.model.query.AssetQuery;
import org.openremote.model.query.filter.RealmPredicate;
import org.openremote.model.security.ClientRole;
import org.openremote.model.util.AttributeConfigurationUtil;
import org.openremote.model.util.TextUtil;
import org.openremote.model.util.ValueUtil;

Expand Down Expand Up @@ -466,6 +471,79 @@
}
}

@Override
public AttributeConfigurationDocument exportAttributeConfiguration(
RequestParams requestParams,
String assetId,
List<String> attributeNames,
List<String> genericPaths) {
// Reuses the read access rules of #get, including the protected view for a restricted user
Asset<?> asset = get(requestParams, assetId);

try {
return AttributeConfigurationUtil.export(asset, attributeNames, genericPaths);
} catch (IllegalArgumentException e) {
throw new BadRequestException(e.getMessage());
}
}

@Override
public AttributeConfigurationImportPreview previewAttributeConfigurationImport(
RequestParams requestParams, String assetId, AttributeConfigurationImportRequest request) {

Asset<?> storageAsset = assetStorageService.find(assetId, true);

if (storageAsset == null) {
LOG.fine("Asset not found: assetID=" + assetId);

Check warning on line 497 in manager/src/main/java/org/openremote/manager/asset/AssetResourceImpl.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the built-in formatting to construct this argument.

See more on https://sonarcloud.io/project/issues?id=openremote_openremote&issues=AaEI2jQ846-QiBaKjzmO&open=AaEI2jQ846-QiBaKjzmO&pullRequest=3380
throw new WebApplicationException(NOT_FOUND);
}

throwIfNotRealmActiveAndAccessible(storageAsset.getRealm());

if (isRestrictedUser() && !assetStorageService.isUserAsset(getUserId(), assetId)) {
LOG.fine(
"Forbidden access for restricted user: username="
+ getUsername()
+ ", assetID="
+ assetId);

Check warning on line 508 in manager/src/main/java/org/openremote/manager/asset/AssetResourceImpl.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the built-in formatting to construct this argument.

See more on https://sonarcloud.io/project/issues?id=openremote_openremote&issues=AaEI2jQ846-QiBaKjzmQ&open=AaEI2jQ846-QiBaKjzmQ&pullRequest=3380
throw new WebApplicationException(FORBIDDEN);
}

if (request == null || request.getDraft() == null) {
throw new WebApplicationException("An asset draft is required", BAD_REQUEST);
}

Asset<?> draft = request.getDraft();

if (!Objects.equals(assetId, draft.getId())
|| !Objects.equals(storageAsset.getRealm(), draft.getRealm())
|| !Objects.equals(storageAsset.getType(), draft.getType())) {
throw importFailure(
AttributeConfigurationImportPreview.failed(
AttributeConfigurationImportFailure.DRAFT_MISMATCH));
}

AttributeConfigurationImportPreview preview =
AttributeConfigurationUtil.preview(
draft, request.getDocument(), request.getGenericParameterValues());

if (preview.getFailure() != null) {
throw importFailure(preview);
}

return preview;
}

protected WebApplicationException importFailure(AttributeConfigurationImportPreview preview) {
Response.Status status =
preview.getFailure() == AttributeConfigurationImportFailure.DRAFT_MISMATCH
? CONFLICT
: BAD_REQUEST;

return new WebApplicationException(
Response.status(status).entity(preview).type(MediaType.APPLICATION_JSON_TYPE).build());
}

@Override
public AttributeWriteResult writeAttributeValue(
RequestParams requestParams, String assetId, String attributeName, Object value) {
Expand Down
91 changes: 91 additions & 0 deletions model/src/main/java/org/openremote/model/asset/AssetResource.java
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,97 @@ Asset<?> update(
value = ASSET_UPDATE)))
Asset<?> asset);

/**
* Retrieve the attribute configuration of the asset as a user editable document, holding the type
* and metadata of every attribute that has metadata. Repeat the attributeName query parameter to
* export a subset of them, and the genericPath query parameter to leave values out for the user
* to supply on import. A 400 status is returned if a generic path does not hold the same text,
* boolean or number in every exported attribute holding it. Access follows the same rules as
* {@link #get}.
*/
@GET
@Path("{assetId}/attributeConfiguration")
@Produces(APPLICATION_JSON)
@RolesAllowed({Constants.READ_ASSETS_ROLE})
@Operation(
operationId = "exportAttributeConfiguration",
summary = "Export the attribute configuration of an asset",
description =
"Returns a versioned document holding the type and metadata of each attribute that has metadata, limited to the requested attribute names when any are given. Attributes without metadata are never included. Values at the requested generic paths are left out and recorded as generic parameters, named after their path, for the user to supply on import.")
@OpenApiResponses.Ok
@OpenApiResponses.Authenticated
@OpenApiResponses.BadRequest
@OpenApiResponses.NotFound
AttributeConfigurationDocument exportAttributeConfiguration(
@BeanParam RequestParams requestParams,
@Parameter(description = ASSET_ID, example = EXAMPLE_ASSET_ID) @PathParam("assetId")
String assetId,
@Parameter(
description =
"Attribute names to export. Repeat the query parameter for multiple attributes, omit it to export all attributes that have metadata.",
style = ParameterStyle.FORM,
explode = Explode.TRUE)
@QueryParam("attributeName")
List<String> attributeNames,
@Parameter(
description =
"Paths relative to an attribute, such as meta.agentLink.id, whose value becomes a generic parameter. Each must hold the same text, boolean or number in every exported attribute holding it. Repeat the query parameter for multiple paths.",
example = "meta.agentLink.id",
style = ParameterStyle.FORM,
explode = Explode.TRUE)
@QueryParam("genericPath")
List<String> genericPaths);

/**
* Validate an attribute configuration document against an asset draft and report what importing
* it would do, without storing anything. The returned patched attributes are for the client to
* apply to the draft once the user confirms, so the same access as {@link #update} is required. A
* 400 status is returned if no draft is given or the document cannot be imported, including while
* its generic parameters lack a valid value, and a 409 status if the draft no longer identifies
* the persisted asset. A rejected document is answered with the preview, whose failure names the
* reason.
*/
@POST
@Path("{assetId}/attributeConfiguration/import")
@Consumes(APPLICATION_JSON)
@Produces(APPLICATION_JSON)
@RolesAllowed({Constants.WRITE_ASSETS_ROLE})
@Operation(
operationId = "previewAttributeConfigurationImport",
summary = "Preview an attribute configuration import",
description =
"Validates the document, matches its attributes against the draft by name and type, and returns the compatibility report together with the draft attributes holding the imported metadata. When the document has generic parameters, the report lists those needing a value and is rejected until valid values are submitted. Nothing is stored; the client applies the patch after the user confirms.")
@OpenApiResponses.Ok
@OpenApiResponses.Authenticated
@ApiResponse(
responseCode = "400",
description =
"The draft is missing, or the document cannot be imported; a rejected document is answered with the preview, whose failure names the reason",
content = {
@Content(
mediaType = APPLICATION_JSON,
schema = @Schema(implementation = AttributeConfigurationImportPreview.class)),
@Content(mediaType = "text/plain", schema = @Schema(implementation = String.class))
})
@OpenApiResponses.NotFound
@ApiResponse(
responseCode = "409",
description =
"The draft no longer identifies the persisted asset; the preview failure is DRAFT_MISMATCH",
content =
@Content(
mediaType = APPLICATION_JSON,
schema = @Schema(implementation = AttributeConfigurationImportPreview.class)))
AttributeConfigurationImportPreview previewAttributeConfigurationImport(
@BeanParam RequestParams requestParams,
@Parameter(description = ASSET_ID, example = EXAMPLE_ASSET_ID) @PathParam("assetId")
String assetId,
@RequestBody(
required = true,
description =
"The asset draft being edited, the contents of the selected configuration file and the values of its generic parameters.")
@NotNull AttributeConfigurationImportRequest request);

/**
* Updates an attribute of an asset. Regular users can only update assets in their authenticated
* realm, the superuser can update assets in other (all) realms. A 403 status is returned if a
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* Copyright 2026, OpenRemote Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package org.openremote.model.asset;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.swagger.v3.oas.annotations.media.Schema;

/**
* The configuration of a single {@link org.openremote.model.attribute.Attribute} within an {@link
* AttributeConfigurationDocument}. The attribute name is the document map key, so it is not
* repeated here.
*/
@Schema(
description =
"Configuration of a single attribute; the attribute name is the key within the document.")
public class AttributeConfiguration {

@Schema(description = "Value descriptor name of the attribute, e.g. number.")
protected String type;

@Schema(description = "Metadata keyed by meta-item name, exactly as stored on the attribute.")
protected ObjectNode meta;

@JsonCreator
public AttributeConfiguration(
@JsonProperty("type") String type, @JsonProperty("meta") ObjectNode meta) {
this.type = type;
this.meta = meta;
}

public String getType() {
return type;
}

public ObjectNode getMeta() {
return meta;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* Copyright 2026, OpenRemote Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package org.openremote.model.asset;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.Map;

/**
* A user editable document holding the configuration of the {@link
* org.openremote.model.attribute.Attribute}s of an {@link Asset}, exchanged as a file by the
* Manager UI. Attributes are keyed by attribute name.
*/
@Schema(
description =
"Versioned document holding attribute configuration of an asset, keyed by attribute name.")
public class AttributeConfigurationDocument {

/** The only document version this release can read and write. */
public static final int VERSION = 1;

@Schema(description = "Document format version.", example = "1")
protected int version;

Check failure on line 40 in model/src/main/java/org/openremote/model/asset/AttributeConfigurationDocument.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename field "version" to prevent any misunderstanding/clash with field "VERSION".

See more on https://sonarcloud.io/project/issues?id=openremote_openremote&issues=AaEI2jPb46-QiBaKjzmN&open=AaEI2jPb46-QiBaKjzmN&pullRequest=3380

@Schema(description = "Asset type the configuration was exported from.")
protected String assetType;

@Schema(
description =
"Values left out of the attribute configuration, keyed by parameter name, for the user to supply on import.")
protected Map<String, AttributeConfigurationGenericParameter> genericParameters;

@Schema(description = "Attribute configuration keyed by attribute name.")
protected Map<String, AttributeConfiguration> attributes;

@JsonCreator
public AttributeConfigurationDocument(
@JsonProperty("version") int version,
@JsonProperty("assetType") String assetType,
@JsonProperty("genericParameters")
Map<String, AttributeConfigurationGenericParameter> genericParameters,
@JsonProperty("attributes") Map<String, AttributeConfiguration> attributes) {
this.version = version;
this.assetType = assetType;
this.genericParameters = genericParameters;
this.attributes = attributes;
}

public int getVersion() {
return version;
}

public String getAssetType() {
return assetType;
}

public Map<String, AttributeConfigurationGenericParameter> getGenericParameters() {
return genericParameters;
}

public Map<String, AttributeConfiguration> getAttributes() {
return attributes;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* Copyright 2026, OpenRemote Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package org.openremote.model.asset;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;

/**
* A value that an {@link AttributeConfigurationDocument} leaves out and asks for on import, applied
* to every path it lists. The parameter name is the document map key, so it is not repeated here.
*/
@Schema(
description =
"A value supplied on import and applied to every listed path; the parameter name is the key within the document.")
public class AttributeConfigurationGenericParameter {

@Schema(
description =
"Value descriptor name of the value, one of text, boolean, integer, long or number.",
example = "text")
protected String type;

@Schema(
description =
"Dot separated paths within the document that receive the value, each of the form attributes.<attribute>.meta.<meta item>[.<property>...].",
example = "[\"attributes.error.meta.agentLink.id\"]")
protected List<String> paths;

@JsonCreator
public AttributeConfigurationGenericParameter(
@JsonProperty("type") String type, @JsonProperty("paths") List<String> paths) {
this.type = type;
this.paths = paths;
}

public String getType() {
return type;
}

public List<String> getPaths() {
return paths;
}
}
Loading
Loading