Skip to content
Open
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.ConfigurationTemplate;
import org.openremote.model.asset.ConfigurationTemplateImportFailure;
import org.openremote.model.asset.ConfigurationTemplateImportPreview;
import org.openremote.model.asset.ConfigurationTemplateImportRequest;
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.ConfigurationTemplateUtil;
import org.openremote.model.util.TextUtil;
import org.openremote.model.util.ValueUtil;

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

@Override
public ConfigurationTemplate exportConfigurationTemplate(
RequestParams requestParams, String assetId, List<String> attributeNames) {
// Reuses the read access rules of #get, including the protected view for a restricted user
ConfigurationTemplate document =
ConfigurationTemplateUtil.export(get(requestParams, assetId), attributeNames);

if (document.getAttributes() == null || document.getAttributes().isEmpty()) {
throw new WebApplicationException("No configuration items to export", BAD_REQUEST);
}
return document;
}

@Override
public ConfigurationTemplateImportPreview previewConfigurationTemplateImport(
RequestParams requestParams, String assetId, ConfigurationTemplateImportRequest request) {

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

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

Check warning on line 494 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 505 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(
ConfigurationTemplateImportPreview.failed(
ConfigurationTemplateImportFailure.DRAFT_MISMATCH));
}

ConfigurationTemplateImportPreview preview =
ConfigurationTemplateUtil.preview(draft, request.getDocument());

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

return preview;
}

protected WebApplicationException importFailure(ConfigurationTemplateImportPreview preview) {
Response.Status status =
preview.getFailure() == ConfigurationTemplateImportFailure.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
81 changes: 81 additions & 0 deletions model/src/main/java/org/openremote/model/asset/AssetResource.java

@Ekhorn Ekhorn Nov 9, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should probably settle whether we want to call these endpoints attributeConfiguration, or instead maybe: configurationTemplate, attributeConfigurationTemplate, attributeConfigurationItemTemplate, etc.. ?

I'm questioning this because we also are considering templates for individual attributes I believe..

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Settled on configuration template, the name the design and dialogs were already using it. "Attribute template" stays free for the planned per-attribute templates.

  • Endpoints: GET /asset/{assetId}/configurationTemplate and POST /asset/{assetId}/configurationTemplate/import
  • Model: ConfigurationTemplate, ConfigurationTemplateImport{Request,Preview,Failure} and ConfigurationTemplateUtil
  • UI: configuration-template.ts, locale keys configurationTemplate.*, download <asset-name>-configuration-template.json
  • AttributeConfiguration, AttributeConfigurationSummary and AttributeConfigurationTypeMismatch keep their names, as each describes a single attribute

Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,87 @@ Asset<?> update(
value = ASSET_UPDATE)))
Asset<?> asset);

/**
* Retrieve a configuration template of the asset: a user editable document holding the type and
* metadata of every attribute that has metadata. Metadata that grants access or links attributes
* stays with the asset. Repeat the attributeName query parameter to export a subset of them.
* Access follows the same rules as {@link #get}. A 400 status is returned if nothing is left to
* export.
*/
@GET
@Path("{assetId}/configurationTemplate")
@Produces(APPLICATION_JSON)
@RolesAllowed({Constants.READ_ASSETS_ROLE})
@Operation(
operationId = "exportConfigurationTemplate",
summary = "Export a configuration template 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. Metadata that grants access or links attributes stays with the asset, and attributes without other metadata are never included. Exporting nothing is rejected.")
@OpenApiResponses.Ok
@OpenApiResponses.Authenticated
@OpenApiResponses.BadRequest
@OpenApiResponses.NotFound
ConfigurationTemplate exportConfigurationTemplate(
@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);

/**
* Validate a configuration template 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 template cannot be imported, and a 409 status if the
* draft no longer identifies the persisted asset. A rejected template is answered with the
* preview, whose failure names the reason.
*/
@POST
@Path("{assetId}/configurationTemplate/import")
@Consumes(APPLICATION_JSON)
@Produces(APPLICATION_JSON)
@RolesAllowed({Constants.WRITE_ASSETS_ROLE})
@Operation(
operationId = "previewConfigurationTemplateImport",
summary = "Preview a configuration template 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. Metadata that grants access or links attributes is ignored, and the draft keeps its own. 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 = ConfigurationTemplateImportPreview.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 = ConfigurationTemplateImportPreview.class)))
ConfigurationTemplateImportPreview previewConfigurationTemplateImport(
@BeanParam RequestParams requestParams,
@Parameter(description = ASSET_ID, example = EXAMPLE_ASSET_ID) @PathParam("assetId")
String assetId,
@RequestBody(
required = true,
description =
"The asset draft being edited and the contents of the selected configuration file.")
@NotNull ConfigurationTemplateImportRequest 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,55 @@
/*
* 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
* ConfigurationTemplate}. 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,49 @@
/*
* 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;

/** An attribute of a {@link ConfigurationTemplate} as reported to the user. */
@Schema(description = "Name and type of an attribute within an imported document.")
public class AttributeConfigurationSummary {

@Schema(description = "Attribute name.")
protected String name;

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

@JsonCreator
public AttributeConfigurationSummary(
@JsonProperty("name") String name, @JsonProperty("type") String type) {
this.name = name;
this.type = type;
}

public String getName() {
return name;
}

public String getType() {
return type;
}
}
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;

/**
* An attribute that cannot be imported because the target attribute holds a different value type.
*/
@Schema(description = "Attribute skipped on import because the target type differs.")
public class AttributeConfigurationTypeMismatch {

@Schema(description = "Attribute name.")
protected String name;

@Schema(description = "Value descriptor name within the imported document.")
protected String importedType;

@Schema(description = "Value descriptor name of the target attribute.")
protected String targetType;

@JsonCreator
public AttributeConfigurationTypeMismatch(
@JsonProperty("name") String name,
@JsonProperty("importedType") String importedType,
@JsonProperty("targetType") String targetType) {
this.name = name;
this.importedType = importedType;
this.targetType = targetType;
}

public String getName() {
return name;
}

public String getImportedType() {
return importedType;
}

public String getTargetType() {
return targetType;
}
}
Loading
Loading