feat(registry): add IoT device connection profile (iot_profiles table)
New optional 1:1 extension to services: providers can declare how IoT
devices connect to their hub (hub_url, protocols, provisioning endpoint,
device classes, firmware compatibility) via PATCH /services/{id}.
- New entity IotProfileEntity + Liquibase changeset 009 (GIN indexes for
jsonb device_classes and protocols arrays)
- IotProtocol / CredentialFormat enums; IotProfileRequest / IotProfileResponse DTOs
- JsonStringListConverter for jsonb List<String> persistence
- GET /services/{id}/replacements extended with iotProfile per candidate
and new filter params: iotReady=true, deviceClass=..., protocol=...
- 6 new BDD scenarios (IotProfileCucumberTest) covering profile creation,
candidate enrichment, iotReady / deviceClass filtering, partial update,
and missing-field 422 validation
- All 57 tests green (6 new + 27 existing transition + 24 unit)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
package org.botstandards.apix.registry.dto;
|
||||
|
||||
public enum CredentialFormat {
|
||||
JWT,
|
||||
PSK,
|
||||
X509,
|
||||
NONE
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.botstandards.apix.registry.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record IotProfileRequest(
|
||||
String hubUrl,
|
||||
List<IotProtocol> protocols,
|
||||
String tlsMinVersion,
|
||||
String provisioningEndpoint,
|
||||
CredentialFormat credentialFormat,
|
||||
String deviceIdField,
|
||||
List<String> deviceClasses,
|
||||
String minFirmwareVersion,
|
||||
String firmwareUpdateUrl,
|
||||
Integer estimatedMigrationMinutes
|
||||
) {}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package org.botstandards.apix.registry.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import org.botstandards.apix.registry.entity.IotProfileEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record IotProfileResponse(
|
||||
String hubUrl,
|
||||
List<String> protocols,
|
||||
String tlsMinVersion,
|
||||
String provisioningEndpoint,
|
||||
String credentialFormat,
|
||||
String deviceIdField,
|
||||
List<String> deviceClasses,
|
||||
String minFirmwareVersion,
|
||||
String firmwareUpdateUrl,
|
||||
Integer estimatedMigrationMinutes
|
||||
) {
|
||||
public static IotProfileResponse from(IotProfileEntity e) {
|
||||
if (e == null) return null;
|
||||
return new IotProfileResponse(
|
||||
e.hubUrl,
|
||||
e.protocols,
|
||||
e.tlsMinVersion,
|
||||
e.provisioningEndpoint,
|
||||
e.credentialFormat != null ? e.credentialFormat.name() : null,
|
||||
e.deviceIdField,
|
||||
e.deviceClasses,
|
||||
e.minFirmwareVersion,
|
||||
e.firmwareUpdateUrl,
|
||||
e.estimatedMigrationMinutes
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.botstandards.apix.registry.dto;
|
||||
|
||||
public enum IotProtocol {
|
||||
MQTT_3_1_1,
|
||||
MQTT_5_0,
|
||||
WEBSOCKET,
|
||||
COAP,
|
||||
HTTP_1_1,
|
||||
AMQP
|
||||
}
|
||||
+3
-1
@@ -3,6 +3,7 @@ package org.botstandards.apix.registry.dto;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import org.botstandards.apix.common.OLevel;
|
||||
import org.botstandards.apix.common.ServiceStage;
|
||||
import org.botstandards.apix.registry.dto.IotProfileResponse;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
@@ -20,6 +21,7 @@ public record ReplacementsResponse(
|
||||
String name,
|
||||
String endpoint,
|
||||
OLevel oLevel,
|
||||
ServiceStage serviceStage
|
||||
ServiceStage serviceStage,
|
||||
IotProfileResponse iotProfile
|
||||
) {}
|
||||
}
|
||||
|
||||
+3
-1
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import org.botstandards.apix.common.BsmPayload;
|
||||
import org.botstandards.apix.common.OrgType;
|
||||
import org.botstandards.apix.common.ServiceStage;
|
||||
import org.botstandards.apix.registry.dto.IotProfileRequest;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
@@ -30,5 +31,6 @@ public record ServicePatchRequest(
|
||||
Boolean locked,
|
||||
Instant sunsetAt,
|
||||
String migrationGuideUrl,
|
||||
List<UUID> replacesServiceIds
|
||||
List<UUID> replacesServiceIds,
|
||||
IotProfileRequest iotProfile
|
||||
) {}
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.botstandards.apix.registry.dto;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import org.botstandards.apix.common.*;
|
||||
import org.botstandards.apix.registry.entity.ServiceEntity;
|
||||
import org.botstandards.apix.registry.dto.IotProfileResponse;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
@@ -35,7 +36,8 @@ public record ServiceResponse(
|
||||
String migrationGuideUrl,
|
||||
List<UUID> replacesServiceIds,
|
||||
Instant registeredAt,
|
||||
Instant lastUpdatedAt
|
||||
Instant lastUpdatedAt,
|
||||
IotProfileResponse iotProfile
|
||||
) {
|
||||
public static ServiceResponse from(ServiceEntity e) {
|
||||
BsmPayload b = e.bsmPayload;
|
||||
@@ -65,7 +67,8 @@ public record ServiceResponse(
|
||||
e.migrationGuideUrl,
|
||||
b.replacesServiceIds(),
|
||||
e.registeredAt,
|
||||
e.lastUpdatedAt
|
||||
e.lastUpdatedAt,
|
||||
IotProfileResponse.from(e.iotProfile)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package org.botstandards.apix.registry.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import org.botstandards.apix.registry.dto.CredentialFormat;
|
||||
import org.botstandards.apix.registry.persistence.JsonStringListConverter;
|
||||
import org.hibernate.annotations.ColumnTransformer;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "iot_profiles")
|
||||
public class IotProfileEntity {
|
||||
|
||||
@Id
|
||||
@Column(columnDefinition = "uuid")
|
||||
public UUID id;
|
||||
|
||||
@OneToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "service_id", nullable = false, unique = true)
|
||||
public ServiceEntity service;
|
||||
|
||||
@Column(name = "hub_url", nullable = false, length = 512)
|
||||
public String hubUrl;
|
||||
|
||||
@Convert(converter = JsonStringListConverter.class)
|
||||
@Column(name = "protocols", columnDefinition = "jsonb", nullable = false)
|
||||
@ColumnTransformer(write = "?::jsonb")
|
||||
public List<String> protocols;
|
||||
|
||||
@Column(name = "tls_min_version", length = 10, nullable = false)
|
||||
public String tlsMinVersion = "1.2";
|
||||
|
||||
@Column(name = "provisioning_endpoint", length = 512)
|
||||
public String provisioningEndpoint;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "credential_format", length = 30)
|
||||
public CredentialFormat credentialFormat;
|
||||
|
||||
@Column(name = "device_id_field", length = 100)
|
||||
public String deviceIdField;
|
||||
|
||||
@Convert(converter = JsonStringListConverter.class)
|
||||
@Column(name = "device_classes", columnDefinition = "jsonb", nullable = false)
|
||||
@ColumnTransformer(write = "?::jsonb")
|
||||
public List<String> deviceClasses;
|
||||
|
||||
@Column(name = "min_firmware_version", length = 50)
|
||||
public String minFirmwareVersion;
|
||||
|
||||
@Column(name = "firmware_update_url", length = 512)
|
||||
public String firmwareUpdateUrl;
|
||||
|
||||
@Column(name = "estimated_migration_minutes")
|
||||
public Integer estimatedMigrationMinutes;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
public Instant createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
public Instant updatedAt;
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import jakarta.persistence.*;
|
||||
import org.botstandards.apix.common.*;
|
||||
import org.botstandards.apix.registry.persistence.BsmPayloadConverter;
|
||||
import org.hibernate.annotations.ColumnTransformer;
|
||||
import org.hibernate.annotations.Fetch;
|
||||
import org.hibernate.annotations.FetchMode;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
@@ -61,4 +63,11 @@ public class ServiceEntity {
|
||||
|
||||
@Column(name = "migration_guide_url")
|
||||
public String migrationGuideUrl;
|
||||
|
||||
// EAGER so the profile is available without an explicit @Transactional on read paths.
|
||||
// iot_profiles rows are sparse — Hibernate uses a LEFT JOIN for em.find()/JPQL,
|
||||
// and a secondary SELECT per entity for native queries. Both are acceptable at registry scale.
|
||||
@OneToOne(mappedBy = "service", fetch = FetchType.EAGER, optional = true)
|
||||
@Fetch(FetchMode.SELECT)
|
||||
public IotProfileEntity iotProfile;
|
||||
}
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package org.botstandards.apix.registry.persistence;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Converter
|
||||
public class JsonStringListConverter implements AttributeConverter<List<String>, String> {
|
||||
|
||||
private static final JsonMapper MAPPER = JsonMapper.builder().build();
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(List<String> attribute) {
|
||||
if (attribute == null) return null;
|
||||
try {
|
||||
return MAPPER.writeValueAsString(attribute);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Cannot serialize string list to JSON", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> convertToEntityAttribute(String dbData) {
|
||||
if (dbData == null) return null;
|
||||
try {
|
||||
return MAPPER.readValue(dbData, new TypeReference<>() {});
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Cannot deserialize JSON to string list", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -77,8 +77,11 @@ public class ServiceResource {
|
||||
@GET
|
||||
@Path("/{id}/replacements")
|
||||
public Response getReplacements(@PathParam("id") UUID id,
|
||||
@QueryParam("minOLevel") String minOLevel) {
|
||||
ReplacementsResponse body = registryService.getReplacements(id, minOLevel);
|
||||
@QueryParam("minOLevel") String minOLevel,
|
||||
@QueryParam("iotReady") Boolean iotReady,
|
||||
@QueryParam("deviceClass") String deviceClass,
|
||||
@QueryParam("protocol") String protocol) {
|
||||
ReplacementsResponse body = registryService.getReplacements(id, minOLevel, iotReady, deviceClass, protocol);
|
||||
return Response.ok(body)
|
||||
.header("Cache-Control", "public, max-age=60")
|
||||
.build();
|
||||
|
||||
+58
-4
@@ -11,12 +11,17 @@ import org.botstandards.apix.common.*;
|
||||
import org.botstandards.apix.registry.dto.ReplacementsResponse;
|
||||
import org.botstandards.apix.registry.dto.ServicePatchRequest;
|
||||
import org.botstandards.apix.registry.dto.VersionHistoryEntry;
|
||||
import org.botstandards.apix.registry.dto.IotProfileRequest;
|
||||
import org.botstandards.apix.registry.dto.IotProfileResponse;
|
||||
import org.botstandards.apix.registry.dto.IotProtocol;
|
||||
import org.botstandards.apix.registry.entity.IotProfileEntity;
|
||||
import org.botstandards.apix.registry.entity.ServiceEntity;
|
||||
import org.botstandards.apix.registry.entity.ServiceReplacementEntity;
|
||||
import org.botstandards.apix.registry.entity.ServiceVersionEntity;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@ApplicationScoped
|
||||
public class RegistryService {
|
||||
@@ -113,6 +118,9 @@ public class RegistryService {
|
||||
if (req.replacesServiceIds() != null) {
|
||||
syncReplacements(service.id, req.replacesServiceIds(), service.lastUpdatedAt);
|
||||
}
|
||||
if (req.iotProfile() != null) {
|
||||
upsertIotProfile(service, req.iotProfile(), service.lastUpdatedAt);
|
||||
}
|
||||
|
||||
return service;
|
||||
}
|
||||
@@ -135,7 +143,8 @@ public class RegistryService {
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public ReplacementsResponse getReplacements(UUID deprecatedId, String minOLevelStr) {
|
||||
public ReplacementsResponse getReplacements(UUID deprecatedId, String minOLevelStr,
|
||||
Boolean iotReady, String deviceClass, String protocol) {
|
||||
ServiceEntity deprecated = requireById(deprecatedId);
|
||||
|
||||
// Locked services that are not yet DECOMMISSIONED block replacement discovery
|
||||
@@ -153,14 +162,30 @@ public class RegistryService {
|
||||
|
||||
OLevel minOLevel = minOLevelStr != null ? OLevel.valueOf(minOLevelStr) : null;
|
||||
|
||||
Stream<ServiceEntity> stream = candidates.stream()
|
||||
.filter(c -> minOLevel == null || c.olevel.ordinal() >= minOLevel.ordinal());
|
||||
if (Boolean.TRUE.equals(iotReady)) {
|
||||
stream = stream.filter(c -> c.iotProfile != null);
|
||||
}
|
||||
if (deviceClass != null && !deviceClass.isBlank()) {
|
||||
stream = stream.filter(c -> c.iotProfile != null
|
||||
&& c.iotProfile.deviceClasses != null
|
||||
&& c.iotProfile.deviceClasses.contains(deviceClass));
|
||||
}
|
||||
if (protocol != null && !protocol.isBlank()) {
|
||||
stream = stream.filter(c -> c.iotProfile != null
|
||||
&& c.iotProfile.protocols != null
|
||||
&& c.iotProfile.protocols.contains(protocol));
|
||||
}
|
||||
|
||||
return new ReplacementsResponse(
|
||||
deprecated.locked,
|
||||
deprecated.sunsetAt,
|
||||
candidates.stream()
|
||||
.filter(c -> minOLevel == null || c.olevel.ordinal() >= minOLevel.ordinal())
|
||||
stream
|
||||
.sorted(Comparator.comparingInt((ServiceEntity c) -> c.olevel.ordinal()).reversed())
|
||||
.map(c -> new ReplacementsResponse.Candidate(
|
||||
c.id, c.bsmPayload.name(), c.endpointUrl, c.olevel, c.serviceStage))
|
||||
c.id, c.bsmPayload.name(), c.endpointUrl, c.olevel, c.serviceStage,
|
||||
IotProfileResponse.from(c.iotProfile)))
|
||||
.toList()
|
||||
);
|
||||
}
|
||||
@@ -290,6 +315,35 @@ public class RegistryService {
|
||||
}
|
||||
}
|
||||
|
||||
private void upsertIotProfile(ServiceEntity service, IotProfileRequest req, Instant now) {
|
||||
IotProfileEntity p = service.iotProfile;
|
||||
boolean isNew = (p == null);
|
||||
if (isNew) {
|
||||
if (req.hubUrl() == null) throw unprocessable("iotProfile.hubUrl is required");
|
||||
if (req.protocols() == null || req.protocols().isEmpty()) throw unprocessable("iotProfile.protocols is required");
|
||||
if (req.deviceClasses() == null || req.deviceClasses().isEmpty()) throw unprocessable("iotProfile.deviceClasses is required");
|
||||
p = new IotProfileEntity();
|
||||
p.id = UUID.randomUUID();
|
||||
p.service = service;
|
||||
p.createdAt = now;
|
||||
}
|
||||
if (req.hubUrl() != null) p.hubUrl = req.hubUrl();
|
||||
if (req.protocols() != null) p.protocols = req.protocols().stream().map(IotProtocol::name).toList();
|
||||
if (req.tlsMinVersion() != null) p.tlsMinVersion = req.tlsMinVersion();
|
||||
if (req.provisioningEndpoint() != null) p.provisioningEndpoint = req.provisioningEndpoint();
|
||||
if (req.credentialFormat() != null) p.credentialFormat = req.credentialFormat();
|
||||
if (req.deviceIdField() != null) p.deviceIdField = req.deviceIdField();
|
||||
if (req.deviceClasses() != null) p.deviceClasses = req.deviceClasses();
|
||||
if (req.minFirmwareVersion() != null) p.minFirmwareVersion = req.minFirmwareVersion();
|
||||
if (req.firmwareUpdateUrl() != null) p.firmwareUpdateUrl = req.firmwareUpdateUrl();
|
||||
if (req.estimatedMigrationMinutes() != null) p.estimatedMigrationMinutes = req.estimatedMigrationMinutes();
|
||||
p.updatedAt = now;
|
||||
if (isNew) {
|
||||
service.iotProfile = p;
|
||||
em.persist(p);
|
||||
}
|
||||
}
|
||||
|
||||
private void upsertReplacement(UUID deprecatedId, UUID replacementId, Instant now) {
|
||||
long count = ((Number) em.createNativeQuery(
|
||||
"SELECT COUNT(*) FROM service_replacements " +
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
|
||||
|
||||
<changeSet id="009" author="apix">
|
||||
<!-- Optional IoT device-connection profile attached to a registered service.
|
||||
A service without an iot_profiles row is a generic API service with no
|
||||
declared device-onboarding support. A service with a row is discoverable
|
||||
by IoT devices polling for a cloud-sunset replacement target. -->
|
||||
<createTable tableName="iot_profiles">
|
||||
<column name="id" type="uuid" defaultValueComputed="gen_random_uuid()">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<!-- Owning FK — one profile per service, deleted with the service -->
|
||||
<column name="service_id" type="uuid">
|
||||
<constraints nullable="false" unique="true"
|
||||
foreignKeyName="fk_iot_profile_service"
|
||||
references="services(id)"
|
||||
deleteCascade="true"/>
|
||||
</column>
|
||||
|
||||
<!-- Device connection -->
|
||||
<!-- WebSocket / MQTT broker / CoAP endpoint the device connects to -->
|
||||
<column name="hub_url" type="varchar(512)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- JSONB array of IotProtocol enum values, e.g. ["MQTT_5_0","WEBSOCKET"] -->
|
||||
<column name="protocols" type="jsonb">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="tls_min_version" type="varchar(10)" defaultValue="1.2">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
|
||||
<!-- Provisioning — device calls this URL directly; APIX never sees device identity -->
|
||||
<column name="provisioning_endpoint" type="varchar(512)"/>
|
||||
<!-- CredentialFormat enum: JWT | PSK | X509 | NONE -->
|
||||
<column name="credential_format" type="varchar(30)"/>
|
||||
<!-- Which device field is presented at provisioning (serialNumber, mac, deviceEui) -->
|
||||
<column name="device_id_field" type="varchar(100)"/>
|
||||
|
||||
<!-- Device class targeting — same capability taxonomy as GET /services?capability -->
|
||||
<!-- JSONB array of class identifiers, e.g. ["device.class.smart-home-hub"] -->
|
||||
<column name="device_classes" type="jsonb">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
|
||||
<!-- Migration aid -->
|
||||
<column name="min_firmware_version" type="varchar(50)"/>
|
||||
<column name="firmware_update_url" type="varchar(512)"/>
|
||||
<!-- Estimated wall-clock minutes to complete the migration (for UX display) -->
|
||||
<column name="estimated_migration_minutes" type="integer"/>
|
||||
|
||||
<column name="created_at" type="timestamptz" defaultValueComputed="now()">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="updated_at" type="timestamptz" defaultValueComputed="now()">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
|
||||
<!-- Liquibase createIndex does not support PostgreSQL-specific index types;
|
||||
GIN must be declared via raw SQL for jsonb containment queries. -->
|
||||
<sql>CREATE INDEX idx_iot_device_classes ON iot_profiles USING gin(device_classes)</sql>
|
||||
<sql>CREATE INDEX idx_iot_protocols ON iot_profiles USING gin(protocols)</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
@@ -13,5 +13,6 @@
|
||||
<include file="changes/006-sunset-lock.xml" relativeToChangelogFile="true"/>
|
||||
<include file="changes/007-service-replacements.xml" relativeToChangelogFile="true"/>
|
||||
<include file="changes/008-sunset-at.xml" relativeToChangelogFile="true"/>
|
||||
<include file="changes/009-iot-profiles.xml" relativeToChangelogFile="true"/>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package org.botstandards.apix.registry.bdd;
|
||||
|
||||
import io.cucumber.core.cli.Main;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* Runs BDD scenarios for the IoT device connection profile feature.
|
||||
* Shares step definitions with IotTransitionCucumberTest via the same glue package.
|
||||
*/
|
||||
@QuarkusTest
|
||||
public class IotProfileCucumberTest {
|
||||
|
||||
@Test
|
||||
public void run() {
|
||||
byte exitCode = Main.run(
|
||||
"--glue", "org.botstandards.apix.registry.bdd",
|
||||
"--plugin", "pretty",
|
||||
"--plugin", "json:target/cucumber-report-iot-profile.json",
|
||||
"--plugin", "io.qameta.allure.cucumber7jvm.AllureCucumber7Jvm",
|
||||
"classpath:features/iot-profile"
|
||||
);
|
||||
assertEquals(0, exitCode, "One or more IoT profile Cucumber scenarios failed");
|
||||
}
|
||||
}
|
||||
+89
@@ -783,4 +783,93 @@ public class IotTransitionSteps {
|
||||
.doesNotContain("user-agent");
|
||||
}
|
||||
}
|
||||
|
||||
// ── IoT Profile — Given (setup) ──────────────────────────────────────────
|
||||
|
||||
@Given("{string} has IoT profile hubUrl {string} protocols {string} deviceClasses {string}")
|
||||
public void hasIotProfile(String name, String hubUrl, String protocols, String deviceClasses) {
|
||||
asTemplateOwner()
|
||||
.body(Map.of("iotProfile", Map.of(
|
||||
"hubUrl", hubUrl,
|
||||
"protocols", List.of(protocols),
|
||||
"deviceClasses", List.of(deviceClasses)
|
||||
)))
|
||||
.patch("/services/" + serviceIds.get(name))
|
||||
.then().statusCode(200);
|
||||
}
|
||||
|
||||
// ── IoT Profile — When ───────────────────────────────────────────────────
|
||||
|
||||
@When("{string} sets its IoT profile with hubUrl {string} protocols {string} deviceClasses {string}")
|
||||
public void setsIotProfile(String name, String hubUrl, String protocols, String deviceClasses) {
|
||||
actionPhase = true;
|
||||
lastResponse = asTemplateOwner()
|
||||
.body(Map.of("iotProfile", Map.of(
|
||||
"hubUrl", hubUrl,
|
||||
"protocols", List.of(protocols),
|
||||
"deviceClasses", List.of(deviceClasses)
|
||||
)))
|
||||
.patch("/services/" + serviceIds.get(name));
|
||||
currentServiceId = serviceIds.get(name);
|
||||
}
|
||||
|
||||
@When("{string} updates its IoT profile hubUrl to {string}")
|
||||
public void updatesIotProfileHubUrl(String name, String hubUrl) {
|
||||
actionPhase = true;
|
||||
lastResponse = asTemplateOwner()
|
||||
.body(Map.of("iotProfile", Map.of("hubUrl", hubUrl)))
|
||||
.patch("/services/" + serviceIds.get(name));
|
||||
currentServiceId = serviceIds.get(name);
|
||||
}
|
||||
|
||||
@When("{string} patches iotProfile with only hubUrl {string}")
|
||||
public void patchesIotProfileWithOnlyHubUrl(String name, String hubUrl) {
|
||||
actionPhase = true;
|
||||
lastResponse = asTemplateOwner()
|
||||
.body(Map.of("iotProfile", Map.of("hubUrl", hubUrl)))
|
||||
.patch("/services/" + serviceIds.get(name));
|
||||
}
|
||||
|
||||
@When("^GET /services/\\{smartHubCloudId\\}/replacements\\?iotReady=true is called with no (?:authentication|Authorization) header$")
|
||||
public void getReplacementsIotReady() {
|
||||
actionPhase = true;
|
||||
lastResponse = given().get(
|
||||
"/services/" + serviceIds.get("SmartHub Cloud") + "/replacements?iotReady=true");
|
||||
}
|
||||
|
||||
@When("^GET /services/\\{smartHubCloudId\\}/replacements\\?deviceClass=([^ ]+) is called with no (?:authentication|Authorization) header$")
|
||||
public void getReplacementsFilteredByDeviceClass(String deviceClass) {
|
||||
actionPhase = true;
|
||||
lastResponse = given().get(
|
||||
"/services/" + serviceIds.get("SmartHub Cloud") + "/replacements?deviceClass=" + deviceClass);
|
||||
}
|
||||
|
||||
// ── IoT Profile — Then ───────────────────────────────────────────────────
|
||||
|
||||
@Then("^GET /services/\\{openHubId\\} includes an iotProfile$")
|
||||
public void getOpenHubIncludesIotProfile() {
|
||||
given().get("/services/" + serviceIds.get("OpenHub"))
|
||||
.then().statusCode(200)
|
||||
.body("iotProfile", notNullValue());
|
||||
}
|
||||
|
||||
@Then("the iotProfile.hubUrl is {string}")
|
||||
public void iotProfileHubUrlIs(String expected) {
|
||||
given().get("/services/" + currentServiceId)
|
||||
.then().statusCode(200)
|
||||
.body("iotProfile.hubUrl", equalTo(expected));
|
||||
}
|
||||
|
||||
@Then("the iotProfile.protocols contains {string}")
|
||||
public void iotProfileProtocolsContains(String protocol) {
|
||||
given().get("/services/" + currentServiceId)
|
||||
.then().statusCode(200)
|
||||
.body("iotProfile.protocols", hasItem(protocol));
|
||||
}
|
||||
|
||||
@Then("the candidate {string} has an iotProfile with hubUrl {string}")
|
||||
public void candidateHasIotProfileWithHubUrl(String name, String hubUrl) {
|
||||
lastResponse.then().statusCode(200)
|
||||
.body("candidates.find { it.name == '" + name + "' }.iotProfile.hubUrl", equalTo(hubUrl));
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -79,7 +79,7 @@ class RegistryServiceTest {
|
||||
var req = new ServicePatchRequest(
|
||||
null, null, null, List.of("new.capability"),
|
||||
null, null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null);
|
||||
null, null, null, null, null, null);
|
||||
assertThat(RegistryService.detectChangeType(ServiceStage.PRODUCTION, null, req))
|
||||
.isEqualTo(ChangeType.BSM_UPDATED);
|
||||
}
|
||||
@@ -188,6 +188,6 @@ class RegistryServiceTest {
|
||||
private static ServicePatchRequest patch(ServiceStage stage, Boolean locked, Instant sunsetAt, List<UUID> replaces) {
|
||||
return new ServicePatchRequest(
|
||||
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
|
||||
stage, locked, sunsetAt, null, replaces);
|
||||
stage, locked, sunsetAt, null, replaces, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
Feature: IoT device connection profile
|
||||
As a service provider targeting IoT device portability
|
||||
I want to declare my device connection profile in the APIX registry
|
||||
So that migrating IoT devices can discover how to connect to my service
|
||||
|
||||
Background:
|
||||
Given a deprecated service "SmartHub Cloud" with locked set to false
|
||||
And a registered service "OpenHub" in stage "PRODUCTION" with O-level "IDENTITY_VERIFIED"
|
||||
And a registered service "CloudBridge" in stage "PRODUCTION" with O-level "LEGAL_ENTITY_VERIFIED"
|
||||
|
||||
Scenario: Provider adds an IoT profile to their service
|
||||
When "OpenHub" sets its IoT profile with hubUrl "wss://hub.openhub.io/v2" protocols "MQTT_5_0" deviceClasses "device.class.smart-home-hub"
|
||||
Then the response is HTTP 200
|
||||
And GET /services/{openHubId} includes an iotProfile
|
||||
And the iotProfile.hubUrl is "wss://hub.openhub.io/v2"
|
||||
And the iotProfile.protocols contains "MQTT_5_0"
|
||||
|
||||
Scenario: IoT profile is included in replacement candidates
|
||||
Given "OpenHub" has IoT profile hubUrl "wss://hub.openhub.io/v2" protocols "MQTT_5_0" deviceClasses "device.class.smart-home-hub"
|
||||
And "OpenHub" has declared compatibility with "SmartHub Cloud"
|
||||
When GET /services/{smartHubCloudId}/replacements is called
|
||||
Then the response is HTTP 200
|
||||
And the candidate "OpenHub" has an iotProfile with hubUrl "wss://hub.openhub.io/v2"
|
||||
|
||||
Scenario: iotReady=true excludes candidates without a profile
|
||||
Given "OpenHub" has declared compatibility with "SmartHub Cloud"
|
||||
And "CloudBridge" has IoT profile hubUrl "wss://hub.cloudbridge.io" protocols "MQTT_5_0" deviceClasses "device.class.smart-home-hub"
|
||||
And "CloudBridge" has declared compatibility with "SmartHub Cloud"
|
||||
When GET /services/{smartHubCloudId}/replacements?iotReady=true is called with no authentication header
|
||||
Then the response is HTTP 200
|
||||
And the response contains 1 candidate
|
||||
And the candidate is "CloudBridge"
|
||||
|
||||
Scenario: deviceClass filter narrows replacement candidates
|
||||
Given "OpenHub" has IoT profile hubUrl "wss://hub.openhub.io" protocols "MQTT_5_0" deviceClasses "device.class.smart-home-hub"
|
||||
And "OpenHub" has declared compatibility with "SmartHub Cloud"
|
||||
And "CloudBridge" has IoT profile hubUrl "wss://hub.cloudbridge.io" protocols "MQTT_5_0" deviceClasses "device.class.industrial-sensor"
|
||||
And "CloudBridge" has declared compatibility with "SmartHub Cloud"
|
||||
When GET /services/{smartHubCloudId}/replacements?deviceClass=device.class.smart-home-hub is called with no authentication header
|
||||
Then the response is HTTP 200
|
||||
And the response contains 1 candidate
|
||||
And the candidate is "OpenHub"
|
||||
|
||||
Scenario: IoT profile fields are partially updated on subsequent PATCH
|
||||
Given "OpenHub" has IoT profile hubUrl "wss://old.openhub.io" protocols "MQTT_5_0" deviceClasses "device.class.smart-home-hub"
|
||||
When "OpenHub" updates its IoT profile hubUrl to "wss://new.openhub.io"
|
||||
Then the response is HTTP 200
|
||||
And GET /services/{openHubId} includes an iotProfile
|
||||
And the iotProfile.hubUrl is "wss://new.openhub.io"
|
||||
And the iotProfile.protocols contains "MQTT_5_0"
|
||||
|
||||
Scenario: Missing required fields on first IoT profile PATCH returns 422
|
||||
When "OpenHub" patches iotProfile with only hubUrl "wss://hub.openhub.io"
|
||||
Then the response is HTTP 422
|
||||
And the error message contains "iotProfile.protocols is required"
|
||||
Reference in New Issue
Block a user