Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
65bbf25
Test that a cloud engine whose product the resource key lacks builds …
jwrosewell Nov 9, 2026
50c4c42
Build a cloud engine whose product the resource key lacks, log that o…
jwrosewell Nov 9, 2026
c72baac
Test that the Cloud Request Engine discovers when it is built, and re…
jwrosewell Nov 9, 2026
a4b6fd5
Discover when the Cloud Request Engine is built, and retry on first u…
jwrosewell Nov 9, 2026
4defa2a
Let a Cloud Request Engine be built from a kept copy of what an earli…
jwrosewell Nov 9, 2026
4f62134
Test that discovery makes both requests at once, times out after 2 se…
jwrosewell Nov 9, 2026
bef79b0
Make the discovery requests at once, time out after 2 seconds by defa…
jwrosewell Nov 9, 2026
be26bcf
Test that the SetHeaders element keeps its configuration once every e…
jwrosewell Nov 9, 2026
9f35ac9
Keep the SetHeaders element's configuration once every element has lo…
jwrosewell Nov 9, 2026
ea5fc04
Give the SetHeaders test mocks their type parameters, as the build tr…
jwrosewell Nov 9, 2026
2f6a4de
Test that the Cloud Request Engine sends only the Evidence the cloud …
jwrosewell Nov 9, 2026
0155c33
Send only the Evidence the cloud accepts from the Cloud Request Engine
jwrosewell Nov 9, 2026
f600e1d
Mark the mocked product map unchecked, as the build treats the vararg…
jwrosewell Nov 9, 2026
efbeed0
Mark the mocked product map unchecked, as the build treats the vararg…
jwrosewell Nov 9, 2026
33a13fe
Mark the mocked product map unchecked, as the build treats the vararg…
jwrosewell Nov 9, 2026
c309b58
Merge #84's test fix into #86
jwrosewell Nov 9, 2026
8f709b4
Stub the product map once per call, so no varargs array is created
jwrosewell Nov 9, 2026
684a837
Merge branch 'fix/cloud-request-engine-eager-discovery' into fix/clou…
jwrosewell Nov 9, 2026
40452b7
Test that a 408 or 429 at discovery is transient and does not fail th…
jwrosewell Nov 9, 2026
7bc481c
Treat a 408 or 429 at discovery as transient, so it does not fail the…
jwrosewell Nov 9, 2026
f522ddc
Merge branch 'fix/cloud-request-engine-eager-discovery' into fix/clou…
jwrosewell Nov 9, 2026
c2a5e9c
Test that the Cloud Request Engine never sends Evidence named licence
jwrosewell Nov 9, 2026
97a73df
Never send Evidence named licence from the Cloud Request Engine
jwrosewell Nov 9, 2026
726204b
Revert "Treat a 408 or 429 at discovery as transient, so it does not …
jwrosewell Nov 9, 2026
19cc0fa
Revert "Test that a 408 or 429 at discovery is transient and does not…
jwrosewell Nov 9, 2026
f755cb5
Merge branch 'fix/cloud-request-engine-eager-discovery' into fix/clou…
jwrosewell Nov 9, 2026
dba6b9a
Test that a missing product is a warning and a missing response part …
jwrosewell Nov 10, 2026
5e2553c
Log a missing product as a warning, and treat a missing response part…
jwrosewell Nov 10, 2026
1e1dae8
Test that a 408 at discovery fails the build, as every 4xx does
jwrosewell Nov 10, 2026
127b173
Test that a 4xx at discovery fails the build when its body is not JSON
jwrosewell Nov 10, 2026
7d4cebd
Keep the status of a cloud answer whose body is not JSON
jwrosewell Nov 10, 2026
38bccd2
Merge branch 'fix/cloud-request-engine-eager-discovery' into fix/clou…
jwrosewell Nov 10, 2026
9fced9b
Rewrap the findProduct Javadoc paragraph
jwrosewell Nov 10, 2026
66326a3
Merge rc, which now holds the license key masking, into eager discovery
jwrosewell Nov 10, 2026
5a6fc84
Merge the missing product change at 9fced9b into eager discovery
jwrosewell Nov 10, 2026
b244e5a
Merge the eager discovery change at 5a6fc84 into sent evidence
jwrosewell Nov 10, 2026
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 @@ -55,9 +55,10 @@
*/
public class DeviceDetectionCloudEngine
extends CloudAspectEngineBase<DeviceDataCloud> {
List<AspectPropertyMetaData> aspectProperties;
volatile List<AspectPropertyMetaData> aspectProperties;
private String dataSourceTier;
CloudRequestEngine cloudRequestEngine;
private final Object metadataLock = new Object();

/**
* Construct a new instance of the {@link DeviceDetectionCloudEngine}.
Expand All @@ -72,9 +73,37 @@ public DeviceDetectionCloudEngine(
this.cloudRequestEngine = null;
}

/**
* The property metadata, from the products the Cloud Request Engine
* reports. Until they are known, after a failure at build that can
* pass, this is empty and is read again on the next call.
* @return the property metadata
*/
@Override
public List<AspectPropertyMetaData> getProperties() {
return aspectProperties;
List<AspectPropertyMetaData> loaded = aspectProperties;
if (loaded == null && cloudRequestEngine != null) {
synchronized (metadataLock) {
if (aspectProperties == null) {
loadAspectProperties(cloudRequestEngine);
}
loaded = aspectProperties;
}
}
return loaded == null ?
new ArrayList<AspectPropertyMetaData>() :
loaded;
}

/**
* Whether the property metadata is loaded, which reads it if the Cloud
* Request Engine now knows the accessible properties.
* @return true once the property metadata is loaded
*/
@Override
public boolean hasLoadedProperties() {
getProperties();
return aspectProperties != null;
}

@Override
Expand Down Expand Up @@ -111,7 +140,7 @@ protected void processEngine(FlowData data, DeviceDataCloud aspectData) {

// Extract data from json to the aspectData instance.
JSONObject jsonObj = new JSONObject(json);
JSONObject deviceObj = jsonObj.getJSONObject("device");
JSONObject deviceObj = getElementJson(jsonObj);

Map<String, Object> deviceMap =
new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
Expand Down Expand Up @@ -188,9 +217,10 @@ public void addPipeline(Pipeline pipeline) {
if (cloudRequestEngine == null) {
cloudRequestEngine = pipeline.getElement(CloudRequestEngine.class);
if (cloudRequestEngine != null) {
if (loadAspectProperties(cloudRequestEngine) == false) {
logger.error("Failed to load aspect properties");
}
// The products are not known after a failure at build that
// can pass, which the Cloud Request Engine has logged.
// getProperties reads them once they are.
loadAspectProperties(cloudRequestEngine);
}

}
Expand All @@ -205,31 +235,28 @@ public void addPipeline(Pipeline pipeline) {
* {@link #aspectProperties}
*/
private boolean loadAspectProperties(CloudRequestEngine engine) {
Map<String, AccessiblePropertyMetaData.ProductMetaData> map =
engine.getPublicProperties();
AccessiblePropertyMetaData.ProductMetaData product =
findProduct(engine.getPublicProperties());

if (map != null &&
map.size() > 0 &&
map.containsKey(getElementDataKey())) {
aspectProperties = new ArrayList<>();
dataSourceTier = map.get(getElementDataKey()).dataTier;
if (product != null) {
List<AspectPropertyMetaData> properties = new ArrayList<>();
dataSourceTier = product.dataTier;

for (AccessiblePropertyMetaData.PropertyMetaData item :
map.get(getElementDataKey()).properties) {
product.properties) {
AspectPropertyMetaData property = new AspectPropertyMetaDataDefault(
item.name,
this,
item.category,
item.getPropertyType(),
new ArrayList<String>(),
true);
aspectProperties.add(property);
properties.add(property);
}
aspectProperties = properties;
return true;
}
else {
logger.error("Aspect properties could not be loaded for" +
" the Device Detection cloud engine", this);
return false;
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
/* *********************************************************************
* This Original Work is copyright of 51 Degrees Mobile Experts Limited.
* Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
* Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
*
* This Original Work is licensed under the European Union Public Licence
* (EUPL) v.1.2 and is subject to its terms as set out below.
*
* If a copy of the EUPL was not distributed with this file, You can obtain
* one at https://opensource.org/licenses/EUPL-1.2.
*
* The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
* amended by the European Commission) shall be deemed incompatible for
* the purposes of the Work and the provisions of the compatibility
* clause in Article 5 of the EUPL shall not apply.
*
* If using the Work as, or as part of, a network application, by
* including the attribution notice(s) required under Article 5 of the EUPL
* in the end user terms of the application under an appropriate heading,
* such notice(s) shall fulfill the requirements of that article.
* ********************************************************************* */

package fiftyone.devicedetection.cloud.flowelements;

import fiftyone.devicedetection.cloud.data.DeviceDataCloud;
import fiftyone.pipeline.cloudrequestengine.data.CloudRequestData;
import fiftyone.pipeline.cloudrequestengine.flowelements.CloudRequestEngine;
import fiftyone.pipeline.core.data.AccessiblePropertyMetaData.ProductMetaData;
import fiftyone.pipeline.core.data.AccessiblePropertyMetaData.PropertyMetaData;
import fiftyone.pipeline.core.data.FlowData;
import fiftyone.pipeline.core.flowelements.Pipeline;
import fiftyone.pipeline.engines.exceptions.PropertyMissingException;
import fiftyone.pipeline.engines.services.MissingPropertyReason;
import fiftyone.pipeline.engines.services.MissingPropertyServiceDefault;
import org.junit.Before;
import org.junit.Test;
import org.mockito.Mockito;
import org.mockito.invocation.Invocation;
import org.slf4j.ILoggerFactory;
import org.slf4j.Logger;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

/**
* A resource key can be valid and include no product that holds the
* Device Detection properties. The engine then builds with empty property
* metadata, logs the missing product once, and logs or throws nothing on
* each request.
*/
public class ProductMissingTests {

private Logger logger;
private ILoggerFactory loggerFactory;

@Before
public void init() {
logger = mock(Logger.class);
loggerFactory = mock(ILoggerFactory.class);
when(loggerFactory.getLogger(anyString())).thenReturn(logger);
}

@Test
public void ProductMissing_BuildsWithEmptyMetadata() throws Exception {
try (DeviceDetectionCloudEngine engine = buildEngine()) {
assertNotNull(engine.getProperties());
assertEquals(0, engine.getProperties().size());
List<String> logged = warningsAndErrors();
assertEquals(logged.toString(), 1, logged.size());
assertTrue(logged.get(0), logged.get(0).startsWith("warn "));
assertTrue(logged.get(0), logged.get(0).contains("'device'"));
}
}

@Test
public void ProductMissing_ProcessLogsAndThrowsNothing() throws Exception {
try (DeviceDetectionCloudEngine engine = buildEngine()) {
for (int i = 0; i < 3; i++) {
process(engine);
}
List<String> logged = warningsAndErrors();
assertEquals(logged.toString(), 1, logged.size());
}
}

/**
* With the Device Detection product included, a response that holds no
* part for it leaves the element data without values and throws
* nothing, as the Processing section of the cloud aspect engine
* specification says.
*/
@Test
public void ProductPresent_ResponseWithoutPart_NoValues()
throws Exception {
try (DeviceDetectionCloudEngine engine = buildEngine("device", "hardwarevendor")) {
DeviceDataCloud data = process(
engine,
"{ 'ip': { 'registeredname': 'Example' }, " +
"'javascriptProperties': [] }");
assertFalse(data.getHardwareVendor().hasValue());
}
}

/**
* Reading a property behaves as for any property the response does not
* carry.
*/
@Test
public void ProductMissing_PropertyIsMissing() throws Exception {
try (DeviceDetectionCloudEngine engine = buildEngine()) {
DeviceDataCloud device = process(engine);
try {
device.getHardwareVendor();
fail("A PropertyMissingException should have been thrown");
} catch (PropertyMissingException e) {
assertEquals(
MissingPropertyReason.ProductNotAccessibleWithResourceKey,
e.getReason());
}
}
}

/**
* After a failure at build that can pass, the Cloud Request Engine does
* not know the accessible products yet. The engine builds without
* logging anything, and reads its metadata once the products are known
* rather than keeping none.
*/
@Test
public void DiscoveryNotComplete_LoadsTheMetadataLater() throws Exception {
ProductMetaData product = new ProductMetaData();
product.dataTier = "test";
product.properties = Arrays.asList(
new PropertyMetaData("hardwarevendor", "String", "cat", null));
Map<String, ProductMetaData> products = new HashMap<>();
products.put("device", product);
CloudRequestEngine requestEngine = mock(CloudRequestEngine.class);
when(requestEngine.getPublicProperties())
.thenReturn(null)
.thenReturn(products);
Pipeline pipeline = mock(Pipeline.class);
when(pipeline.getElement(CloudRequestEngine.class))
.thenReturn(requestEngine);

try (DeviceDetectionCloudEngine engine =
new DeviceDetectionCloudEngineBuilder(loggerFactory).build()) {
engine.addPipeline(pipeline);
List<String> logged = warningsAndErrors();
assertEquals(logged.toString(), 0, logged.size());

assertEquals(1, engine.getProperties().size());
assertEquals("hardwarevendor", engine.getProperties().get(0).getName());
}
}

/**
* An engine whose Cloud Request Engine reports one product, holding
* one string property.
*/
// thenReturn has a varargs overload, so a generic Map is an unchecked array.
@SuppressWarnings("unchecked")
private DeviceDetectionCloudEngine buildEngine(
String productKey,
String property) throws Exception {
DeviceDetectionCloudEngine engine =
new DeviceDetectionCloudEngineBuilder(loggerFactory).build();

ProductMetaData product = new ProductMetaData();
product.dataTier = "test";
product.properties = Arrays.asList(
new PropertyMetaData(property, "String", "cat", null));
Map<String, ProductMetaData> publicProperties = new HashMap<>();
publicProperties.put(productKey, product);

CloudRequestEngine requestEngine = mock(CloudRequestEngine.class);
when(requestEngine.getPublicProperties()).thenReturn(publicProperties);

Pipeline pipeline = mock(Pipeline.class);
when(pipeline.getElement(CloudRequestEngine.class))
.thenReturn(requestEngine);

engine.addPipeline(pipeline);
return engine;
}

/**
* Process the given cloud response.
*/
private DeviceDataCloud process(
DeviceDetectionCloudEngine engine,
String response) {
CloudRequestData cloudData = mock(CloudRequestData.class);
when(cloudData.getJsonResponse()).thenReturn(response);

FlowData flowData = mock(FlowData.class);
when(flowData.getFromElement(any(CloudRequestEngine.class)))
.thenReturn(cloudData);

DeviceDataCloud device = new DeviceDataCloudInternal(
loggerFactory.getLogger(DeviceDataCloud.class.getSimpleName()),
flowData,
engine,
MissingPropertyServiceDefault.getInstance());
engine.processEngine(flowData, device);
return device;
}

/**
* An engine on a resource key that includes IP Intelligence alone.
*/
private DeviceDetectionCloudEngine buildEngine() throws Exception {
return buildEngine("ip", "registeredname");
}

/**
* Process a cloud response that holds nothing for the Device Detection
* product.
*/
private DeviceDataCloud process(DeviceDetectionCloudEngine engine) {
return process(
engine,
"{ 'ip': { 'registeredname': 'Example' }, " +
"'javascriptProperties': [] }");
}

/**
* Every warning and error logged, at any overload.
*/
private List<String> warningsAndErrors() {
List<String> logged = new ArrayList<>();
for (Invocation invocation :
Mockito.mockingDetails(logger).getInvocations()) {
String level = invocation.getMethod().getName();
if (level.equals("warn") || level.equals("error")) {
logged.add(level + " " +
Arrays.toString(invocation.getArguments()));
}
}
return Collections.unmodifiableList(logged);
}
}
Loading
Loading