Skip to content

Commit e851483

Browse files
authored
Merge pull request #75 from PTCInc/dev-1.1
SDK Release 1.1
2 parents ce0d40c + fe7846f commit e851483

File tree

80 files changed

+15600
-361
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

80 files changed

+15600
-361
lines changed

.github/workflows/nuget-test-and-build.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,39 @@ jobs:
7171
run: dotnet restore
7272
- name: Build
7373
run: dotnet build --no-restore --configuration release
74+
75+
# - name: Show current directory (debug)
76+
# run: |
77+
# echo "Current directory: $(pwd)"
78+
# - name: Show repo files (debug)
79+
# if: ${{ matrix.platform == 'ubuntu' }}
80+
# run: |
81+
# echo "Repo root: $(pwd)"
82+
# ls -la
83+
# echo "List _data:"
84+
# ls -la Kepware.Api.Test/_data || true
85+
# echo "List _data/projectLoadSerializeData:"
86+
# ls -la Kepware.Api.Test/_data/projectLoadSerializeData || true
87+
88+
# - name: Build test project (debug)
89+
# if: ${{ matrix.platform == 'ubuntu' }}
90+
# run: dotnet build Kepware.Api.Test/Kepware.Api.Test.csproj -c Release
91+
92+
# - name: Show build output (debug)
93+
# if: ${{ matrix.platform == 'ubuntu' }}
94+
# run: ls -la Kepware.Api.Test/bin/Release || true
95+
96+
# - name: Show TFM outputs (debug)
97+
# if: ${{ matrix.platform == 'ubuntu' }}
98+
# run: ls -la Kepware.Api.Test/bin/Release/* || true
99+
100+
# - name: Show test _data (debug)
101+
# if: ${{ matrix.platform == 'ubuntu' }}
102+
# run: ls -la Kepware.Api.Test/bin/Release/net8.0/_data || true
103+
# - name: Show test _data/projectLoadSerializeData (debug)
104+
# if: ${{ matrix.platform == 'ubuntu' }}
105+
# run: ls -la Kepware.Api.Test/bin/Release/net8.0/_data/projectLoadSerializeData || true
106+
74107
- name: Test
75108
run: dotnet test Kepware.Api.Test/Kepware.Api.Test.csproj --no-build --verbosity normal --configuration Release --logger "trx;LogFilePrefix=${{ matrix.platform }}-test-results"
76109
- name: Publish Test Reports (${{ matrix.platform }})

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ docs/docfx/api/
88
docs/docfx/_*
99
docs/docfx/Kepware*
1010
docs/docfx/*.md
11+
.API *
1112

1213
# User-specific files
1314
*.rsuser

Kepware.Api.Sample/Program.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,47 @@ static async Task Main(string[] args)
5757

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

60+
// --- IoT Gateway: MQTT Client Agent ---
61+
var mqttAgent = await api.Project.IotGateway.GetOrCreateMqttClientAgentAsync("MQTT Agent by Api");
62+
mqttAgent.Url = "tcp://broker.example.com:1883";
63+
mqttAgent.Topic = "kepware/data";
64+
await api.Project.IotGateway.UpdateMqttClientAgentAsync(mqttAgent);
65+
66+
// Add an IoT Item referencing a tag on the device created above
67+
var mqttItem = await api.Project.IotGateway.GetOrCreateIotItemAsync(
68+
$"{channel1.Name}.{device.Name}.BooleanByApi", mqttAgent);
69+
mqttItem.ScanRateMs = 500;
70+
await api.Project.IotGateway.UpdateIotItemAsync(mqttItem);
71+
72+
// Clean up MQTT IoT Item and agent
73+
await api.Project.IotGateway.DeleteIotItemAsync(mqttItem);
74+
await api.Project.IotGateway.DeleteMqttClientAgentAsync(mqttAgent);
75+
76+
// --- IoT Gateway: REST Client Agent ---
77+
var restClientAgent = await api.Project.IotGateway.GetOrCreateRestClientAgentAsync("REST Client by Api");
78+
restClientAgent.Url = "https://api.example.com/data";
79+
restClientAgent.HttpMethod = RestClientHttpMethod.Post;
80+
await api.Project.IotGateway.UpdateRestClientAgentAsync(restClientAgent);
81+
82+
// Add an IoT Item to the REST Client agent
83+
var restClientItem = await api.Project.IotGateway.GetOrCreateIotItemAsync(
84+
$"{channel1.Name}.{device.Name}.SineByApi", restClientAgent);
85+
await api.Project.IotGateway.DeleteIotItemAsync(restClientItem);
86+
await api.Project.IotGateway.DeleteRestClientAgentAsync(restClientAgent);
87+
88+
// --- IoT Gateway: REST Server Agent ---
89+
var restServerAgent = await api.Project.IotGateway.GetOrCreateRestServerAgentAsync("REST Server by Api");
90+
restServerAgent.PortNumber = 39321;
91+
restServerAgent.EnableWriteEndpoint = true;
92+
await api.Project.IotGateway.UpdateRestServerAgentAsync(restServerAgent);
93+
94+
// Add an IoT Item to the REST Server agent
95+
var restServerItem = await api.Project.IotGateway.GetOrCreateIotItemAsync(
96+
$"{channel1.Name}.{device.Name}.RampByApi", restServerAgent);
97+
await api.Project.IotGateway.DeleteIotItemAsync(restServerItem);
98+
await api.Project.IotGateway.DeleteRestServerAgentAsync(restServerAgent);
99+
100+
// Clean up channel and device
60101
await api.Project.Devices.DeleteDeviceAsync(device);
61102
await api.Project.Channels.DeleteChannelAsync(channel1);
62103

Kepware.Api.Test/ApiClient/DeleteTests.cs

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using Moq.Contrib.HttpClient;
55
using Shouldly;
66
using System.Net;
7+
using System.Linq;
78

89
namespace Kepware.Api.Test.ApiClient;
910

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

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

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

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

381385
// Assert
382-
result.ShouldBeTrue();
386+
result.Length.ShouldBe(0);
383387
_httpMessageHandlerMock.VerifyRequest(HttpMethod.Delete, $"{TEST_ENDPOINT}{endpoint}", Times.Never());
384388
}
389+
390+
[Fact]
391+
public async Task Delete_MultipleItems_WithMixedResults_ShouldReturnOrderedResults()
392+
{
393+
// Arrange
394+
var channel = new Channel { Name = "TestChannel" };
395+
var device = new Device { Name = "ParentDevice", Owner = channel };
396+
var tags = CreateTestTags();
397+
var firstEndpoint = $"/config/v1/project/channels/{channel.Name}/devices/{device.Name}/tags/{tags[0].Name}";
398+
var secondEndpoint = $"/config/v1/project/channels/{channel.Name}/devices/{device.Name}/tags/{tags[1].Name}";
399+
400+
_httpMessageHandlerMock.SetupRequest(HttpMethod.Delete, $"{TEST_ENDPOINT}{firstEndpoint}")
401+
.ReturnsResponse(HttpStatusCode.OK);
402+
_httpMessageHandlerMock.SetupRequest(HttpMethod.Delete, $"{TEST_ENDPOINT}{secondEndpoint}")
403+
.ReturnsResponse(HttpStatusCode.InternalServerError, "Server Error");
404+
405+
// Act
406+
var result = await _kepwareApiClient.GenericConfig.DeleteItemsAsync<DeviceTagCollection, Tag>(tags, owner: device);
407+
408+
// Assert
409+
result.Length.ShouldBe(tags.Count);
410+
result[0].ShouldBeTrue();
411+
result[1].ShouldBeFalse();
412+
_httpMessageHandlerMock.VerifyRequest(HttpMethod.Delete, $"{TEST_ENDPOINT}{firstEndpoint}", Times.Once());
413+
_httpMessageHandlerMock.VerifyRequest(HttpMethod.Delete, $"{TEST_ENDPOINT}{secondEndpoint}", Times.Once());
414+
_loggerMockGeneric.Verify(logger =>
415+
logger.Log(
416+
LogLevel.Error,
417+
It.IsAny<EventId>(),
418+
It.Is<It.IsAnyType>((v, t) => true),
419+
It.IsAny<Exception>(),
420+
It.Is<Func<It.IsAnyType, Exception?, string>>((v, t) => true)),
421+
Times.Once);
422+
}
385423
}
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Net;
4+
using System.Net.Http;
5+
using System.Reflection;
6+
using System.Text.Json;
7+
using System.Threading.Tasks;
8+
using Kepware.Api.Model;
9+
using Kepware.Api.Test.ApiClient;
10+
using Microsoft.Extensions.Logging;
11+
using Moq;
12+
using Moq.Contrib.HttpClient;
13+
using Shouldly;
14+
using Xunit;
15+
16+
namespace Kepware.Api.Test.ApiClient
17+
{
18+
public class GenericHandler : TestApiClientBase
19+
{
20+
[Fact]
21+
public async Task CompareAndApplyDetailedAsync_ShouldCountUpdateAsFailed_When200ContainsNotApplied()
22+
{
23+
// Arrange
24+
var channel = new Channel { Name = "Channel1" };
25+
channel.SetDynamicProperty(Properties.Channel.DeviceDriver, "Simulator");
26+
27+
var sourceDevice = new Device { Name = "Device1", Description = "new description", Owner = channel };
28+
var targetDevice = new Device { Name = "Device1", Description = "old description", Owner = channel };
29+
30+
var sourceCollection = new DeviceCollection { sourceDevice };
31+
var targetCollection = new DeviceCollection { targetDevice };
32+
33+
_httpMessageHandlerMock.SetupRequest(HttpMethod.Get, TEST_ENDPOINT + "/config/v1/project/channels/Channel1/devices/Device1")
34+
.ReturnsResponse(HttpStatusCode.OK, JsonSerializer.Serialize(targetDevice), "application/json");
35+
36+
_httpMessageHandlerMock.SetupRequest(HttpMethod.Put, TEST_ENDPOINT + "/config/v1/project/channels/Channel1/devices/Device1")
37+
.ReturnsResponse(HttpStatusCode.OK,
38+
"""
39+
{
40+
"not_applied": {
41+
"servermain.DEVICE_ID_OCTAL": 1,
42+
"servermain.DEVICE_MODEL": 0
43+
},
44+
"code": 200,
45+
"message": "Not all properties were applied."
46+
}
47+
""",
48+
"application/json");
49+
50+
// Act
51+
var result = await _kepwareApiClient.GenericConfig.CompareAndApplyDetailedAsync<DeviceCollection, Device>(sourceCollection, targetCollection, channel);
52+
53+
// Assert
54+
result.Updates.ShouldBe(0);
55+
result.Failures.ShouldBe(1);
56+
result.FailureList.Count.ShouldBe(1);
57+
result.FailureList[0].Operation.ShouldBe(ApplyOperation.Update);
58+
(result.FailureList[0].AttemptedItem as Device)?.Name.ShouldBe("Device1");
59+
result.FailureList[0].NotAppliedProperties.ShouldNotBeNull();
60+
result.FailureList[0].NotAppliedProperties!.ShouldContain("servermain.DEVICE_ID_OCTAL");
61+
result.FailureList[0].NotAppliedProperties!.ShouldContain("servermain.DEVICE_MODEL");
62+
}
63+
64+
[Fact]
65+
public async Task CompareAndApplyDetailedAsync_ShouldMap207InsertFeedbackToItems()
66+
{
67+
// Arrange
68+
var channel = new Channel { Name = "Channel1" };
69+
channel.SetDynamicProperty(Properties.Channel.DeviceDriver, "Simulator");
70+
var ownerDevice = new Device { Name = "Device1", Owner = channel };
71+
72+
var tag1 = new Tag { Name = "Tag1", TagAddress = "RAMP" };
73+
var tag2 = new Tag { Name = "Tag2", TagAddress = "SINE" };
74+
75+
var sourceTags = new DeviceTagCollection { tag1, tag2 };
76+
77+
_httpMessageHandlerMock.SetupRequest(HttpMethod.Post, TEST_ENDPOINT + "/config/v1/project/channels/Channel1/devices/Device1/tags")
78+
.ReturnsResponse((HttpStatusCode)207,
79+
"""
80+
[
81+
{
82+
"property": "common.ALLTYPES_NAME",
83+
"description": "The name 'Tag1' is already used.",
84+
"error_line": 3,
85+
"code": 400,
86+
"message": "Validation failed on property common.ALLTYPES_NAME in object definition at line 3: The name 'Tag1' is already used."
87+
},
88+
{
89+
"code": 201,
90+
"message": "Created"
91+
}
92+
]
93+
""",
94+
"application/json");
95+
96+
// Act
97+
var result = await _kepwareApiClient.GenericConfig.CompareAndApplyDetailedAsync<DeviceTagCollection, Tag>(sourceTags, null, ownerDevice);
98+
99+
// Assert
100+
result.Inserts.ShouldBe(1);
101+
result.Failures.ShouldBe(1);
102+
result.FailureList.Count.ShouldBe(1);
103+
result.FailureList[0].Operation.ShouldBe(ApplyOperation.Insert);
104+
(result.FailureList[0].AttemptedItem as Tag)?.Name.ShouldBe("Tag1");
105+
result.FailureList[0].ResponseCode.ShouldBe(400);
106+
result.FailureList[0].Property.ShouldBe("common.ALLTYPES_NAME");
107+
result.FailureList[0].Description.ShouldBe("The name 'Tag1' is already used.");
108+
result.FailureList[0].ErrorLine.ShouldBe(3);
109+
}
110+
111+
[Fact]
112+
public void AppendQueryString_PrivateMethod_EncodesAndSkipsNullsAndAppendsCorrectly()
113+
{
114+
// Arrange
115+
var method = typeof(Kepware.Api.ClientHandler.GenericApiHandler)
116+
.GetMethod("AppendQueryString", BindingFlags.NonPublic | BindingFlags.Static);
117+
Assert.NotNull(method);
118+
119+
var query = new[]
120+
{
121+
new KeyValuePair<string, string?>("a", "b"),
122+
new KeyValuePair<string, string?>("space", "x y"),
123+
new KeyValuePair<string, string?>("skip", null) // should be skipped
124+
};
125+
126+
// Act
127+
var result1 = (string)method!.Invoke(null, new object[] { "https://api/config", query })!;
128+
var result2 = (string)method!.Invoke(null, new object[] { "https://api/config?existing=1", query })!;
129+
130+
// Assert
131+
Assert.Equal("https://api/config?a=b&space=x%20y", result1);
132+
Assert.Equal("https://api/config?existing=1&a=b&space=x%20y", result2);
133+
}
134+
135+
[Fact]
136+
public async Task LoadCollectionAsync_AppendsQueryAndReturnsCollection()
137+
{
138+
// Arrange
139+
var channelsJson = """
140+
[
141+
{
142+
"PROJECT_ID": 676550906,
143+
"common.ALLTYPES_NAME": "Channel1",
144+
"common.ALLTYPES_DESCRIPTION": "Example Simulator Channel",
145+
"servermain.MULTIPLE_TYPES_DEVICE_DRIVER": "Simulator"
146+
},
147+
{
148+
"PROJECT_ID": 676550906,
149+
"common.ALLTYPES_NAME": "Data Type Examples",
150+
"common.ALLTYPES_DESCRIPTION": "Example Simulator Channel",
151+
"servermain.MULTIPLE_TYPES_DEVICE_DRIVER": "Simulator"
152+
}
153+
]
154+
""";
155+
156+
var query = new[]
157+
{
158+
new KeyValuePair<string, string?>("status", "active"),
159+
new KeyValuePair<string, string?>("name", "John Doe"),
160+
new KeyValuePair<string, string?>("skip", null)
161+
};
162+
163+
// Expect encoded space in "John Doe" and null entry skipped
164+
_httpMessageHandlerMock.SetupRequest(HttpMethod.Get, TEST_ENDPOINT + "/config/v1/project/channels?status=active&name=John%20Doe")
165+
.ReturnsResponse(channelsJson, "application/json");
166+
167+
// Act
168+
var result = await _kepwareApiClient.GenericConfig.LoadCollectionAsync<ChannelCollection, Channel>((string?)null, query);
169+
170+
// Assert
171+
Assert.NotNull(result);
172+
Assert.Equal(2, result.Count);
173+
Assert.Contains(result, c => c.Name == "Channel1");
174+
Assert.Contains(result, c => c.Name == "Data Type Examples");
175+
}
176+
177+
[Fact]
178+
public async Task LoadEntityAsync_AppendsQueryAndReturnsEntity()
179+
{
180+
// Arrange
181+
var channelJson = """
182+
{
183+
"PROJECT_ID": 676550906,
184+
"common.ALLTYPES_NAME": "Channel1",
185+
"common.ALLTYPES_DESCRIPTION": "Example Simulator Channel",
186+
"servermain.MULTIPLE_TYPES_DEVICE_DRIVER": "Simulator"
187+
}
188+
""";
189+
190+
var query = new[]
191+
{
192+
new KeyValuePair<string, string?>("status", "active"),
193+
new KeyValuePair<string, string?>("name", "John Doe"),
194+
new KeyValuePair<string, string?>("skip", null)
195+
};
196+
197+
// Expect encoded space in "John Doe" and null entry skipped
198+
_httpMessageHandlerMock.SetupRequest(HttpMethod.Get, TEST_ENDPOINT + "/config/v1/project/channels/Channel1?status=active&name=John%20Doe")
199+
.ReturnsResponse(channelJson, "application/json");
200+
201+
// Act
202+
var result = await _kepwareApiClient.GenericConfig.LoadEntityAsync<Channel>("Channel1", query);
203+
204+
// Assert
205+
Assert.NotNull(result);
206+
Assert.Equal("Channel1", result.Name);
207+
Assert.Equal("Example Simulator Channel", result.Description);
208+
}
209+
}
210+
}

0 commit comments

Comments
 (0)