Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
bf59eb9
feat(api): cache ProductInfo for client when requested
rlabbeptc Mar 6, 2026
bf78c05
test(api): Added unit and integration testing for caching ProductInfo
rlabbeptc Mar 6, 2026
9d5fd71
feat(api): added generic methods to clear connection and other caches
rlabbeptc Mar 7, 2026
a2a67de
Merge pull request #66 from PTCInc/feat(api)-cache-ProductInfo
rlabbeptc Mar 7, 2026
a1ace8e
chore(test): Update file name
rlabbeptc Mar 13, 2026
2d7b193
feat(api): Query Parameter support for Load Methods and Serialize han…
rlabbeptc Mar 14, 2026
d022f55
feat(api): Enhanced LoadProject to use content=serialze option in an …
rlabbeptc Mar 16, 2026
e27b4fc
feat(api): Added ProjectLoadTagLimit as a configurable property for c…
rlabbeptc Mar 16, 2026
7dd31af
feat(api): added DeviceDriver to device model
rlabbeptc Mar 16, 2026
b19ee4c
chore: translated comment
rlabbeptc Mar 16, 2026
b178ba2
chore: ensure _data files are output in build
rlabbeptc Mar 16, 2026
7c60442
feat(api): extended optimized recursion ProjectLoad to tag groups
rlabbeptc Mar 17, 2026
4579a07
refactor: various property and comment cleanup
rlabbeptc Mar 17, 2026
2c28872
doc: updated readme
rlabbeptc Mar 20, 2026
3249f54
Merge branch 'main' into feat_update_project_load
rlabbeptc Mar 20, 2026
13a64f6
chore: updated with new LoadProjectAsync method call name
rlabbeptc Mar 20, 2026
779b1d6
fix(test): fixed file name for case sensitive linux environments
rlabbeptc Mar 20, 2026
5990ac4
Merge pull request #70 from PTCInc/feat_update_project_load
rlabbeptc Mar 20, 2026
3af3254
feat(api): generic deleteitems returns array of bool results
rlabbeptc Mar 27, 2026
0468978
feat(api): Detailed response options for ProjectLoad and compareAndAp…
rlabbeptc Mar 27, 2026
0898abc
refactor: modified method name
rlabbeptc Mar 27, 2026
389d923
chore: removed excess logging
rlabbeptc Mar 27, 2026
9231406
refactor: renamed properties
rlabbeptc Mar 27, 2026
2256870
feat(sync): utilized new Project CompareAndApplyDetailedAsync method …
rlabbeptc Mar 27, 2026
27cf682
refactor: changed access scope
rlabbeptc Mar 27, 2026
b49cedb
chore(doc): updated docstrings
rlabbeptc Mar 27, 2026
e5bc482
Merge pull request #71 from PTCInc/feat_return_failures_Project_Compa…
rlabbeptc Mar 27, 2026
7c8d6c8
chore: Updated property options to appsettings.json
rlabbeptc Mar 31, 2026
886264c
feat(api): IoT GW added models and associate tests
rlabbeptc Mar 31, 2026
63f3438
feat(api): IoT GW Add client handlers and testing
rlabbeptc Mar 31, 2026
8b003d2
refactor: Moved IoT GW into Project namespace for client handlers
rlabbeptc Mar 31, 2026
c6e5110
feat(api): Integrate IoT GW into Project context and management
rlabbeptc Apr 1, 2026
a0882f7
chore: Add TODO for future review
rlabbeptc Apr 1, 2026
aaabe45
chore: updated gitignore
rlabbeptc Apr 1, 2026
123a4ea
test: Added IoT GW Integration Tests
rlabbeptc Apr 2, 2026
344cd17
test: Update UaEndpoint tests
rlabbeptc Apr 2, 2026
fd46092
feat(sample):: updated sample with IoT GW
rlabbeptc Apr 2, 2026
24d9355
doc: Updated Readme with IoT GW
rlabbeptc Apr 2, 2026
45e90bc
Merge pull request #74 from PTCInc/feat_add_iot_gateway_support
rlabbeptc Apr 2, 2026
fd83c25
fix: test data copy issue during test builds
rlabbeptc Apr 2, 2026
69a6afe
fix: test data copy issue during test builds
rlabbeptc Apr 2, 2026
87f4b61
refactor: fixed issue with test data naming for linux testing
rlabbeptc Apr 2, 2026
fe7846f
chore(sdk): update versioning to 1.1
rlabbeptc Apr 2, 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
33 changes: 33 additions & 0 deletions .github/workflows/nuget-test-and-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,39 @@ jobs:
run: dotnet restore
- name: Build
run: dotnet build --no-restore --configuration release

# - name: Show current directory (debug)
# run: |
# echo "Current directory: $(pwd)"
# - name: Show repo files (debug)
# if: ${{ matrix.platform == 'ubuntu' }}
# run: |
# echo "Repo root: $(pwd)"
# ls -la
# echo "List _data:"
# ls -la Kepware.Api.Test/_data || true
# echo "List _data/projectLoadSerializeData:"
# ls -la Kepware.Api.Test/_data/projectLoadSerializeData || true

# - name: Build test project (debug)
# if: ${{ matrix.platform == 'ubuntu' }}
# run: dotnet build Kepware.Api.Test/Kepware.Api.Test.csproj -c Release

# - name: Show build output (debug)
# if: ${{ matrix.platform == 'ubuntu' }}
# run: ls -la Kepware.Api.Test/bin/Release || true

# - name: Show TFM outputs (debug)
# if: ${{ matrix.platform == 'ubuntu' }}
# run: ls -la Kepware.Api.Test/bin/Release/* || true

# - name: Show test _data (debug)
# if: ${{ matrix.platform == 'ubuntu' }}
# run: ls -la Kepware.Api.Test/bin/Release/net8.0/_data || true
# - name: Show test _data/projectLoadSerializeData (debug)
# if: ${{ matrix.platform == 'ubuntu' }}
# run: ls -la Kepware.Api.Test/bin/Release/net8.0/_data/projectLoadSerializeData || true

- name: Test
run: dotnet test Kepware.Api.Test/Kepware.Api.Test.csproj --no-build --verbosity normal --configuration Release --logger "trx;LogFilePrefix=${{ matrix.platform }}-test-results"
- name: Publish Test Reports (${{ matrix.platform }})
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ docs/docfx/api/
docs/docfx/_*
docs/docfx/Kepware*
docs/docfx/*.md
.API *

# User-specific files
*.rsuser
Expand Down
41 changes: 41 additions & 0 deletions Kepware.Api.Sample/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,47 @@ static async Task Main(string[] args)

await api.Project.Devices.UpdateDeviceAsync(device, true);

// --- IoT Gateway: MQTT Client Agent ---
var mqttAgent = await api.Project.IotGateway.GetOrCreateMqttClientAgentAsync("MQTT Agent by Api");
mqttAgent.Url = "tcp://broker.example.com:1883";
mqttAgent.Topic = "kepware/data";
await api.Project.IotGateway.UpdateMqttClientAgentAsync(mqttAgent);

// Add an IoT Item referencing a tag on the device created above
var mqttItem = await api.Project.IotGateway.GetOrCreateIotItemAsync(
$"{channel1.Name}.{device.Name}.BooleanByApi", mqttAgent);
mqttItem.ScanRateMs = 500;
await api.Project.IotGateway.UpdateIotItemAsync(mqttItem);

// Clean up MQTT IoT Item and agent
await api.Project.IotGateway.DeleteIotItemAsync(mqttItem);
await api.Project.IotGateway.DeleteMqttClientAgentAsync(mqttAgent);

// --- IoT Gateway: REST Client Agent ---
var restClientAgent = await api.Project.IotGateway.GetOrCreateRestClientAgentAsync("REST Client by Api");
restClientAgent.Url = "https://api.example.com/data";
restClientAgent.HttpMethod = RestClientHttpMethod.Post;
await api.Project.IotGateway.UpdateRestClientAgentAsync(restClientAgent);

// Add an IoT Item to the REST Client agent
var restClientItem = await api.Project.IotGateway.GetOrCreateIotItemAsync(
$"{channel1.Name}.{device.Name}.SineByApi", restClientAgent);
await api.Project.IotGateway.DeleteIotItemAsync(restClientItem);
await api.Project.IotGateway.DeleteRestClientAgentAsync(restClientAgent);

// --- IoT Gateway: REST Server Agent ---
var restServerAgent = await api.Project.IotGateway.GetOrCreateRestServerAgentAsync("REST Server by Api");
restServerAgent.PortNumber = 39321;
restServerAgent.EnableWriteEndpoint = true;
await api.Project.IotGateway.UpdateRestServerAgentAsync(restServerAgent);

// Add an IoT Item to the REST Server agent
var restServerItem = await api.Project.IotGateway.GetOrCreateIotItemAsync(
$"{channel1.Name}.{device.Name}.RampByApi", restServerAgent);
await api.Project.IotGateway.DeleteIotItemAsync(restServerItem);
await api.Project.IotGateway.DeleteRestServerAgentAsync(restServerAgent);

// Clean up channel and device
await api.Project.Devices.DeleteDeviceAsync(device);
await api.Project.Channels.DeleteChannelAsync(channel1);

Expand Down
46 changes: 42 additions & 4 deletions Kepware.Api.Test/ApiClient/DeleteTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Moq.Contrib.HttpClient;
using Shouldly;
using System.Net;
using System.Linq;

namespace Kepware.Api.Test.ApiClient;

Expand Down Expand Up @@ -302,7 +303,8 @@ public async Task Delete_MultipleItems_WhenSuccessful_ShouldDeleteAll()
var result = await _kepwareApiClient.GenericConfig.DeleteItemsAsync<DeviceTagCollection, Tag>(tags, owner: device);

// Assert
result.ShouldBeTrue();
result.Length.ShouldBe(tags.Count);
result.All(r => r).ShouldBeTrue();
foreach (var tag in tags)
{
var endpoint = $"/config/v1/project/channels/{channel.Name}/devices/{device.Name}/tags/{tag.Name}";
Expand All @@ -326,7 +328,8 @@ public async Task Delete_MultipleItems_WithHttpError_ShouldLogError()
var result = await _kepwareApiClient.GenericConfig.DeleteItemsAsync<DeviceTagCollection, Tag>(tags, owner: device);

// Assert
result.ShouldBeFalse();
result.Length.ShouldBe(tags.Count);
result[0].ShouldBeFalse();
_httpMessageHandlerMock.VerifyRequest(HttpMethod.Delete, $"{TEST_ENDPOINT}{endpoint}", Times.Once());
_loggerMockGeneric.Verify(logger =>
logger.Log(
Expand Down Expand Up @@ -354,7 +357,8 @@ public async Task Delete_MultipleItems_WithConnectionError_ShouldHandleGracefull
var result = await _kepwareApiClient.GenericConfig.DeleteItemsAsync<DeviceTagCollection, Tag>(tags, owner: device);

// Assert
result.ShouldBeFalse();
result.Length.ShouldBe(tags.Count);
result[0].ShouldBeFalse();
_httpMessageHandlerMock.VerifyRequest(HttpMethod.Delete, $"{TEST_ENDPOINT}{endpoint}", Times.Once());
_loggerMockGeneric.Verify(logger =>
logger.Log(
Expand All @@ -379,7 +383,41 @@ public async Task Delete_MultipleItems_WithEmptyList_ShouldNoop()
var result = await _kepwareApiClient.GenericConfig.DeleteItemsAsync<DeviceTagCollection, Tag>(tags, owner: device);

// Assert
result.ShouldBeTrue();
result.Length.ShouldBe(0);
_httpMessageHandlerMock.VerifyRequest(HttpMethod.Delete, $"{TEST_ENDPOINT}{endpoint}", Times.Never());
}

[Fact]
public async Task Delete_MultipleItems_WithMixedResults_ShouldReturnOrderedResults()
{
// Arrange
var channel = new Channel { Name = "TestChannel" };
var device = new Device { Name = "ParentDevice", Owner = channel };
var tags = CreateTestTags();
var firstEndpoint = $"/config/v1/project/channels/{channel.Name}/devices/{device.Name}/tags/{tags[0].Name}";
var secondEndpoint = $"/config/v1/project/channels/{channel.Name}/devices/{device.Name}/tags/{tags[1].Name}";

_httpMessageHandlerMock.SetupRequest(HttpMethod.Delete, $"{TEST_ENDPOINT}{firstEndpoint}")
.ReturnsResponse(HttpStatusCode.OK);
_httpMessageHandlerMock.SetupRequest(HttpMethod.Delete, $"{TEST_ENDPOINT}{secondEndpoint}")
.ReturnsResponse(HttpStatusCode.InternalServerError, "Server Error");

// Act
var result = await _kepwareApiClient.GenericConfig.DeleteItemsAsync<DeviceTagCollection, Tag>(tags, owner: device);

// Assert
result.Length.ShouldBe(tags.Count);
result[0].ShouldBeTrue();
result[1].ShouldBeFalse();
_httpMessageHandlerMock.VerifyRequest(HttpMethod.Delete, $"{TEST_ENDPOINT}{firstEndpoint}", Times.Once());
_httpMessageHandlerMock.VerifyRequest(HttpMethod.Delete, $"{TEST_ENDPOINT}{secondEndpoint}", Times.Once());
_loggerMockGeneric.Verify(logger =>
logger.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => true),
It.IsAny<Exception>(),
It.Is<Func<It.IsAnyType, Exception?, string>>((v, t) => true)),
Times.Once);
}
}
210 changes: 210 additions & 0 deletions Kepware.Api.Test/ApiClient/GenericHandleTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Reflection;
using System.Text.Json;
using System.Threading.Tasks;
using Kepware.Api.Model;
using Kepware.Api.Test.ApiClient;
using Microsoft.Extensions.Logging;
using Moq;
using Moq.Contrib.HttpClient;
using Shouldly;
using Xunit;

namespace Kepware.Api.Test.ApiClient
{
public class GenericHandler : TestApiClientBase
{
[Fact]
public async Task CompareAndApplyDetailedAsync_ShouldCountUpdateAsFailed_When200ContainsNotApplied()
{
// Arrange
var channel = new Channel { Name = "Channel1" };
channel.SetDynamicProperty(Properties.Channel.DeviceDriver, "Simulator");

var sourceDevice = new Device { Name = "Device1", Description = "new description", Owner = channel };
var targetDevice = new Device { Name = "Device1", Description = "old description", Owner = channel };

var sourceCollection = new DeviceCollection { sourceDevice };
var targetCollection = new DeviceCollection { targetDevice };

_httpMessageHandlerMock.SetupRequest(HttpMethod.Get, TEST_ENDPOINT + "/config/v1/project/channels/Channel1/devices/Device1")
.ReturnsResponse(HttpStatusCode.OK, JsonSerializer.Serialize(targetDevice), "application/json");

_httpMessageHandlerMock.SetupRequest(HttpMethod.Put, TEST_ENDPOINT + "/config/v1/project/channels/Channel1/devices/Device1")
.ReturnsResponse(HttpStatusCode.OK,
"""
{
"not_applied": {
"servermain.DEVICE_ID_OCTAL": 1,
"servermain.DEVICE_MODEL": 0
},
"code": 200,
"message": "Not all properties were applied."
}
""",
"application/json");

// Act
var result = await _kepwareApiClient.GenericConfig.CompareAndApplyDetailedAsync<DeviceCollection, Device>(sourceCollection, targetCollection, channel);

// Assert
result.Updates.ShouldBe(0);
result.Failures.ShouldBe(1);
result.FailureList.Count.ShouldBe(1);
result.FailureList[0].Operation.ShouldBe(ApplyOperation.Update);
(result.FailureList[0].AttemptedItem as Device)?.Name.ShouldBe("Device1");
result.FailureList[0].NotAppliedProperties.ShouldNotBeNull();
result.FailureList[0].NotAppliedProperties!.ShouldContain("servermain.DEVICE_ID_OCTAL");
result.FailureList[0].NotAppliedProperties!.ShouldContain("servermain.DEVICE_MODEL");
}

[Fact]
public async Task CompareAndApplyDetailedAsync_ShouldMap207InsertFeedbackToItems()
{
// Arrange
var channel = new Channel { Name = "Channel1" };
channel.SetDynamicProperty(Properties.Channel.DeviceDriver, "Simulator");
var ownerDevice = new Device { Name = "Device1", Owner = channel };

var tag1 = new Tag { Name = "Tag1", TagAddress = "RAMP" };
var tag2 = new Tag { Name = "Tag2", TagAddress = "SINE" };

var sourceTags = new DeviceTagCollection { tag1, tag2 };

_httpMessageHandlerMock.SetupRequest(HttpMethod.Post, TEST_ENDPOINT + "/config/v1/project/channels/Channel1/devices/Device1/tags")
.ReturnsResponse((HttpStatusCode)207,
"""
[
{
"property": "common.ALLTYPES_NAME",
"description": "The name 'Tag1' is already used.",
"error_line": 3,
"code": 400,
"message": "Validation failed on property common.ALLTYPES_NAME in object definition at line 3: The name 'Tag1' is already used."
},
{
"code": 201,
"message": "Created"
}
]
""",
"application/json");

// Act
var result = await _kepwareApiClient.GenericConfig.CompareAndApplyDetailedAsync<DeviceTagCollection, Tag>(sourceTags, null, ownerDevice);

// Assert
result.Inserts.ShouldBe(1);
result.Failures.ShouldBe(1);
result.FailureList.Count.ShouldBe(1);
result.FailureList[0].Operation.ShouldBe(ApplyOperation.Insert);
(result.FailureList[0].AttemptedItem as Tag)?.Name.ShouldBe("Tag1");
result.FailureList[0].ResponseCode.ShouldBe(400);
result.FailureList[0].Property.ShouldBe("common.ALLTYPES_NAME");
result.FailureList[0].Description.ShouldBe("The name 'Tag1' is already used.");
result.FailureList[0].ErrorLine.ShouldBe(3);
}

[Fact]
public void AppendQueryString_PrivateMethod_EncodesAndSkipsNullsAndAppendsCorrectly()
{
// Arrange
var method = typeof(Kepware.Api.ClientHandler.GenericApiHandler)
.GetMethod("AppendQueryString", BindingFlags.NonPublic | BindingFlags.Static);
Assert.NotNull(method);

var query = new[]
{
new KeyValuePair<string, string?>("a", "b"),
new KeyValuePair<string, string?>("space", "x y"),
new KeyValuePair<string, string?>("skip", null) // should be skipped
};

// Act
var result1 = (string)method!.Invoke(null, new object[] { "https://api/config", query })!;
var result2 = (string)method!.Invoke(null, new object[] { "https://api/config?existing=1", query })!;

// Assert
Assert.Equal("https://api/config?a=b&space=x%20y", result1);
Assert.Equal("https://api/config?existing=1&a=b&space=x%20y", result2);
}

[Fact]
public async Task LoadCollectionAsync_AppendsQueryAndReturnsCollection()
{
// Arrange
var channelsJson = """
[
{
"PROJECT_ID": 676550906,
"common.ALLTYPES_NAME": "Channel1",
"common.ALLTYPES_DESCRIPTION": "Example Simulator Channel",
"servermain.MULTIPLE_TYPES_DEVICE_DRIVER": "Simulator"
},
{
"PROJECT_ID": 676550906,
"common.ALLTYPES_NAME": "Data Type Examples",
"common.ALLTYPES_DESCRIPTION": "Example Simulator Channel",
"servermain.MULTIPLE_TYPES_DEVICE_DRIVER": "Simulator"
}
]
""";

var query = new[]
{
new KeyValuePair<string, string?>("status", "active"),
new KeyValuePair<string, string?>("name", "John Doe"),
new KeyValuePair<string, string?>("skip", null)
};

// Expect encoded space in "John Doe" and null entry skipped
_httpMessageHandlerMock.SetupRequest(HttpMethod.Get, TEST_ENDPOINT + "/config/v1/project/channels?status=active&name=John%20Doe")
.ReturnsResponse(channelsJson, "application/json");

// Act
var result = await _kepwareApiClient.GenericConfig.LoadCollectionAsync<ChannelCollection, Channel>((string?)null, query);

// Assert
Assert.NotNull(result);
Assert.Equal(2, result.Count);
Assert.Contains(result, c => c.Name == "Channel1");
Assert.Contains(result, c => c.Name == "Data Type Examples");
}

[Fact]
public async Task LoadEntityAsync_AppendsQueryAndReturnsEntity()
{
// Arrange
var channelJson = """
{
"PROJECT_ID": 676550906,
"common.ALLTYPES_NAME": "Channel1",
"common.ALLTYPES_DESCRIPTION": "Example Simulator Channel",
"servermain.MULTIPLE_TYPES_DEVICE_DRIVER": "Simulator"
}
""";

var query = new[]
{
new KeyValuePair<string, string?>("status", "active"),
new KeyValuePair<string, string?>("name", "John Doe"),
new KeyValuePair<string, string?>("skip", null)
};

// Expect encoded space in "John Doe" and null entry skipped
_httpMessageHandlerMock.SetupRequest(HttpMethod.Get, TEST_ENDPOINT + "/config/v1/project/channels/Channel1?status=active&name=John%20Doe")
.ReturnsResponse(channelJson, "application/json");

// Act
var result = await _kepwareApiClient.GenericConfig.LoadEntityAsync<Channel>("Channel1", query);

// Assert
Assert.NotNull(result);
Assert.Equal("Channel1", result.Name);
Assert.Equal("Example Simulator Channel", result.Description);
}
}
}
Loading
Loading