refactor(mqtt): accept a blank jsonPath; guide with a seeded "$" instead
Review follow-up. Dropped the hard Validate rejection of a blank jsonPath under a Json payload: the runtime defaults it to the document root, which is the real "publisher puts a bare JSON scalar on the topic" case, so rejecting it made the editor refuse what the driver accepts — inverting "authoring surface accepts <=> publish accepts" — and blocked whole CSV-import batches, since this validator also gates RawManualTagEntryModal's review grid. Replaced with guidance: an UNAUTHORED tag (no topic AND no jsonPath) seeds the field with "$", so a fresh tag emits an explicit "jsonPath":"$" while an existing path-less tag keeps the key ABSENT and the driver defaults it. The wildcard-topic rule stays strict — a wildcard has no legitimate single-Tag interpretation. Also: omit a blank "topic" rather than writing "topic":"", and record why the _lastConfigJson guard diverges from the Modbus template — the landmine is a DERIVED, NON-PERSISTED UI FIELD INFERRED FROM BLOB CONTENT (Mode), which Task 24's Sparkplug field group will be tempted to add more of. Named the host-side dependency (RawTagModal only mutates through this callback) that keeps the staleness trade benign today. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
+16
-2
@@ -133,8 +133,22 @@
|
|||||||
apply();
|
apply();
|
||||||
_validationError = _m.Validate();
|
_validationError = _m.Validate();
|
||||||
var json = _m.ToJson();
|
var json = _m.ToJson();
|
||||||
// Keep the guard in OnParametersSet in step with what we just emitted, so the host echoing the
|
|
||||||
// new JSON back as a parameter cannot re-parse and clobber an in-progress edit.
|
// Keep the OnParametersSet guard in step with what we just emitted, so the host echoing the new
|
||||||
|
// JSON straight back as a parameter cannot re-parse and clobber an in-progress edit.
|
||||||
|
//
|
||||||
|
// WHY THIS DIVERGES FROM THE MODBUS TEMPLATE (which does not do this): the general landmine is a
|
||||||
|
// DERIVED, NON-PERSISTED UI FIELD INFERRED FROM BLOB CONTENT. Mode is exactly that — it is
|
||||||
|
// re-inferred by FromJson from the Sparkplug keys and never serialised, so a plain re-parse of
|
||||||
|
// our own output would silently reset the operator's shape selection to Plain on the very next
|
||||||
|
// render. Modbus has no such field, which is why it needs no guard. Task 24's Sparkplug field
|
||||||
|
// group will be tempted to add more of them; each one needs this guard to hold.
|
||||||
|
//
|
||||||
|
// TRADE (does not manifest today): the editor will not pick up a change the HOST makes to the
|
||||||
|
// JSON while it is open. That is safe only because RawTagModal/RawManualTagEntryModal mutate
|
||||||
|
// _form.TagConfig exclusively through this callback. A host-side feature that rewrites the blob
|
||||||
|
// out-of-band — a "reformat JSON" action, a bulk address re-pick — would reintroduce staleness
|
||||||
|
// here and must re-key the guard (e.g. compare against a host-supplied revision, not the text).
|
||||||
_lastConfigJson = json;
|
_lastConfigJson = json;
|
||||||
await ConfigJsonChanged.InvokeAsync(json);
|
await ConfigJsonChanged.InvokeAsync(json);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ public sealed class MqttTagConfigModel
|
|||||||
private const string QosKey = "qos";
|
private const string QosKey = "qos";
|
||||||
private const string RetainSeedKey = "retainSeed";
|
private const string RetainSeedKey = "retainSeed";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The JSONPath the driver applies when the blob omits <c>jsonPath</c> — the document root.
|
||||||
|
/// Seeded into an unauthored tag's field so the "no extraction needed" case is one click away
|
||||||
|
/// rather than something the operator has to know.
|
||||||
|
/// </summary>
|
||||||
|
private const string RootJsonPath = "$";
|
||||||
|
|
||||||
/// <summary>The Sparkplug B descriptor keys — authored by Task 24, preserved (never dropped) in P1.</summary>
|
/// <summary>The Sparkplug B descriptor keys — authored by Task 24, preserved (never dropped) in P1.</summary>
|
||||||
private static readonly string[] SparkplugKeys = ["groupId", "edgeNodeId", "deviceId", "metricName"];
|
private static readonly string[] SparkplugKeys = ["groupId", "edgeNodeId", "deviceId", "metricName"];
|
||||||
|
|
||||||
@@ -57,8 +64,11 @@ public sealed class MqttTagConfigModel
|
|||||||
public MqttPayloadFormat PayloadFormat { get; set; } = MqttPayloadFormat.Json;
|
public MqttPayloadFormat PayloadFormat { get; set; } = MqttPayloadFormat.Json;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// JSONPath selecting the value inside a <see cref="MqttPayloadFormat.Json"/> payload. Blank ⇒
|
/// JSONPath selecting the value inside a <see cref="MqttPayloadFormat.Json"/> payload. Blank is
|
||||||
/// the key is omitted and the driver applies the document root (<c>$</c>).
|
/// legal and NOT a validation failure — the key is omitted and the driver applies the document
|
||||||
|
/// root (<c>$</c>), which is the real "publisher puts a bare JSON scalar on the topic" case.
|
||||||
|
/// An <b>unauthored</b> tag is seeded with <c>$</c> by <see cref="FromJson"/> so that common case
|
||||||
|
/// is visible and one click away; see <see cref="RootJsonPath"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string JsonPath { get; set; } = "";
|
public string JsonPath { get; set; } = "";
|
||||||
|
|
||||||
@@ -84,17 +94,29 @@ public sealed class MqttTagConfigModel
|
|||||||
/// original key (so fields this editor doesn't expose — history intent, array/alarm objects, and
|
/// original key (so fields this editor doesn't expose — history intent, array/alarm objects, and
|
||||||
/// the Sparkplug descriptor keys — survive a load→save).
|
/// the Sparkplug descriptor keys — survive a load→save).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// An <b>unauthored</b> Plain tag — no <c>topic</c> AND no <c>jsonPath</c>, i.e. a brand-new tag
|
||||||
|
/// rather than an existing one the operator deliberately left path-less — has its
|
||||||
|
/// <see cref="JsonPath"/> seeded to <see cref="RootJsonPath"/>. The condition is deliberately
|
||||||
|
/// narrow: an existing tag that carries a topic and no <c>jsonPath</c> keeps the key ABSENT
|
||||||
|
/// through a load→save, so this can never rewrite already-deployed blobs.
|
||||||
|
/// </remarks>
|
||||||
/// <param name="json">The raw TagConfig JSON string, or <c>null</c> for a new/empty config.</param>
|
/// <param name="json">The raw TagConfig JSON string, or <c>null</c> for a new/empty config.</param>
|
||||||
/// <returns>The populated <see cref="MqttTagConfigModel"/>.</returns>
|
/// <returns>The populated <see cref="MqttTagConfigModel"/>.</returns>
|
||||||
public static MqttTagConfigModel FromJson(string? json)
|
public static MqttTagConfigModel FromJson(string? json)
|
||||||
{
|
{
|
||||||
var o = TagConfigJson.ParseOrNew(json);
|
var o = TagConfigJson.ParseOrNew(json);
|
||||||
|
var topic = TagConfigJson.GetString(o, TopicKey) ?? "";
|
||||||
|
var jsonPath = TagConfigJson.GetString(o, JsonPathKey) ?? "";
|
||||||
|
var mode = InferMode(o);
|
||||||
|
if (mode == MqttMode.Plain && topic.Length == 0 && jsonPath.Length == 0) { jsonPath = RootJsonPath; }
|
||||||
|
|
||||||
return new MqttTagConfigModel
|
return new MqttTagConfigModel
|
||||||
{
|
{
|
||||||
Mode = InferMode(o),
|
Mode = mode,
|
||||||
Topic = TagConfigJson.GetString(o, TopicKey) ?? "",
|
Topic = topic,
|
||||||
PayloadFormat = TagConfigJson.GetEnum(o, PayloadFormatKey, MqttPayloadFormat.Json),
|
PayloadFormat = TagConfigJson.GetEnum(o, PayloadFormatKey, MqttPayloadFormat.Json),
|
||||||
JsonPath = TagConfigJson.GetString(o, JsonPathKey) ?? "",
|
JsonPath = jsonPath,
|
||||||
DataType = TagConfigJson.GetEnum(o, DataTypeKey, DriverDataType.String),
|
DataType = TagConfigJson.GetEnum(o, DataTypeKey, DriverDataType.String),
|
||||||
Qos = GetIntNullable(o, QosKey),
|
Qos = GetIntNullable(o, QosKey),
|
||||||
RetainSeed = TagConfigJson.GetBoolNullable(o, RetainSeedKey),
|
RetainSeed = TagConfigJson.GetBoolNullable(o, RetainSeedKey),
|
||||||
@@ -111,7 +133,9 @@ public sealed class MqttTagConfigModel
|
|||||||
/// <returns>The serialised TagConfig JSON string.</returns>
|
/// <returns>The serialised TagConfig JSON string.</returns>
|
||||||
public string ToJson()
|
public string ToJson()
|
||||||
{
|
{
|
||||||
TagConfigJson.Set(_bag, TopicKey, Topic.Trim());
|
// Blank ⇒ omit, like every other optional here: toggling the shape dropdown on an untouched tag
|
||||||
|
// must not leave a stray "topic":"" behind.
|
||||||
|
TagConfigJson.Set(_bag, TopicKey, string.IsNullOrWhiteSpace(Topic) ? null : Topic.Trim());
|
||||||
TagConfigJson.Set(_bag, PayloadFormatKey, PayloadFormat);
|
TagConfigJson.Set(_bag, PayloadFormatKey, PayloadFormat);
|
||||||
TagConfigJson.Set(_bag, DataTypeKey, DataType);
|
TagConfigJson.Set(_bag, DataTypeKey, DataType);
|
||||||
TagConfigJson.Set(_bag, JsonPathKey, string.IsNullOrWhiteSpace(JsonPath) ? null : JsonPath.Trim());
|
TagConfigJson.Set(_bag, JsonPathKey, string.IsNullOrWhiteSpace(JsonPath) ? null : JsonPath.Trim());
|
||||||
@@ -125,14 +149,26 @@ public sealed class MqttTagConfigModel
|
|||||||
/// Returns the first error, or <c>null</c> when the config is valid.
|
/// Returns the first error, or <c>null</c> when the config is valid.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Two rules are deliberately <b>stricter</b> than the runtime parser, because this is an
|
/// <para>
|
||||||
/// authoring surface and the cheapest place to stop the mistake: a wildcard topic (the runtime
|
/// Exactly ONE rule is deliberately <b>stricter</b> than the runtime parser: a wildcard topic,
|
||||||
/// accepts it and <c>Inspect</c> only warns at deploy) and a <c>Json</c> payload with no
|
/// which the runtime accepts and <c>Inspect</c> only warns about at deploy. It earns the
|
||||||
/// <c>jsonPath</c> (the runtime defaults to the document root). Everything else — required
|
/// strictness because a wildcard has no legitimate single-Tag interpretation — one Tag holds one
|
||||||
/// topic, strict enums, QoS 0–2 — matches <c>MqttTagDefinitionFactory</c> exactly. The strict
|
/// value, and <c>+</c>/<c>#</c> would feed it from many source topics. Everything else —
|
||||||
/// enum/QoS checks read the ORIGINAL key bag, not the defaulted typed fields, so a blob that
|
/// required topic, strict enums, QoS 0–2 — matches <c>MqttTagDefinitionFactory</c> exactly.
|
||||||
/// never went through this editor (e.g. a CSV import) cannot pass validation while the driver
|
/// </para>
|
||||||
/// would reject it.
|
/// <para>
|
||||||
|
/// A blank <c>jsonPath</c> under a <c>Json</c> payload is explicitly <b>accepted</b>, matching
|
||||||
|
/// the runtime's document-root default. Rejecting it would make this editor refuse a config the
|
||||||
|
/// driver handles happily — inverting the "authoring surface accepts ⇔ publish accepts"
|
||||||
|
/// principle — and, because this validator also gates the CSV-import review grid
|
||||||
|
/// (<c>RawManualTagEntryModal</c>), would block whole import batches over a sane default. The
|
||||||
|
/// operator is guided by the seeded <c>$</c> from <see cref="FromJson"/> instead of a blocker.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The strict enum/QoS checks read the ORIGINAL key bag, not the defaulted typed fields, so a
|
||||||
|
/// blob that never went through this editor (e.g. a CSV import) cannot pass validation while the
|
||||||
|
/// driver would reject it.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
|
/// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
|
||||||
public string? Validate()
|
public string? Validate()
|
||||||
@@ -153,11 +189,8 @@ public sealed class MqttTagConfigModel
|
|||||||
+ "or the tag would be fed by every matching topic.";
|
+ "or the tag would be fed by every matching topic.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (PayloadFormat == MqttPayloadFormat.Json && string.IsNullOrWhiteSpace(JsonPath))
|
// NB no jsonPath rule — see the remarks above. A blank path is the driver's document-root
|
||||||
{
|
// default, not an authoring error.
|
||||||
return "A jsonPath is required when the payload format is Json (use \"$\" for the whole document).";
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ public sealed class MqttTagConfigModelTests
|
|||||||
m.Mode.ShouldBe(MqttMode.Plain);
|
m.Mode.ShouldBe(MqttMode.Plain);
|
||||||
m.Topic.ShouldBe("");
|
m.Topic.ShouldBe("");
|
||||||
m.PayloadFormat.ShouldBe(MqttPayloadFormat.Json);
|
m.PayloadFormat.ShouldBe(MqttPayloadFormat.Json);
|
||||||
m.JsonPath.ShouldBe("");
|
// An UNAUTHORED tag seeds the document-root path so the common "no extraction needed" case is
|
||||||
|
// visible and one click away — it is a guide, not a requirement (blank is accepted, see below).
|
||||||
|
m.JsonPath.ShouldBe("$");
|
||||||
m.DataType.ShouldBe(DriverDataType.String);
|
m.DataType.ShouldBe(DriverDataType.String);
|
||||||
m.Qos.ShouldBeNull();
|
m.Qos.ShouldBeNull();
|
||||||
m.RetainSeed.ShouldBeNull();
|
m.RetainSeed.ShouldBeNull();
|
||||||
@@ -107,6 +109,15 @@ public sealed class MqttTagConfigModelTests
|
|||||||
json.ShouldNotContain("jsonPath");
|
json.ShouldNotContain("jsonPath");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToJson_omits_a_blank_topic()
|
||||||
|
{
|
||||||
|
// Toggling the shape dropdown on an untouched tag must not leave a stray "topic":"" behind.
|
||||||
|
var json = new MqttTagConfigModel { PayloadFormat = MqttPayloadFormat.Raw }.ToJson();
|
||||||
|
|
||||||
|
json.ShouldNotContain("topic");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FromJson_then_ToJson_preserves_unknown_keys()
|
public void FromJson_then_ToJson_preserves_unknown_keys()
|
||||||
{
|
{
|
||||||
@@ -171,13 +182,38 @@ public sealed class MqttTagConfigModelTests
|
|||||||
=> MqttTagConfigModel.FromJson($$"""{"topic":"{{topic}}","payloadFormat":"Raw","dataType":"String"}""")
|
=> MqttTagConfigModel.FromJson($$"""{"topic":"{{topic}}","payloadFormat":"Raw","dataType":"String"}""")
|
||||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||||
|
|
||||||
|
// A blank jsonPath under a Json payload is ACCEPTED — the runtime defaults it to the document root
|
||||||
|
// ("$"), which is the real "publisher puts a bare JSON scalar on the topic" case. Rejecting it would
|
||||||
|
// make the editor refuse what the driver accepts, and would block CSV-import batches (this validator
|
||||||
|
// also gates RawManualTagEntryModal's review grid) over a sane default.
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_JsonWithoutPath_Fails()
|
public void Validate_JsonWithoutPath_IsAccepted()
|
||||||
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Json","dataType":"Float64"}""")
|
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Json","dataType":"Float64"}""")
|
||||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
.Validate().ShouldBeNull();
|
||||||
|
|
||||||
|
// …and the key stays ABSENT through a load→save, so the driver applies its own default rather than
|
||||||
|
// this editor freezing "$" into an already-deployed blob.
|
||||||
|
[Fact]
|
||||||
|
public void ToJson_leaves_an_existing_blank_jsonPath_absent()
|
||||||
|
{
|
||||||
|
var json = MqttTagConfigModel
|
||||||
|
.FromJson("""{"topic":"a/b","payloadFormat":"Json","dataType":"Float64"}""")
|
||||||
|
.ToJson();
|
||||||
|
|
||||||
|
json.ShouldNotContain("jsonPath");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The seed applies ONLY to an unauthored tag (no topic AND no jsonPath) — a brand-new tag emits an
|
||||||
|
// explicit "$" once the operator touches anything, which is the guided half of the deal.
|
||||||
|
[Fact]
|
||||||
|
public void ToJson_seeds_the_root_jsonPath_on_a_fresh_tag()
|
||||||
|
{
|
||||||
|
var m = MqttTagConfigModel.FromJson(null);
|
||||||
|
m.Topic = "a/b";
|
||||||
|
|
||||||
|
m.ToJson().ShouldContain("\"jsonPath\":\"$\"");
|
||||||
|
}
|
||||||
|
|
||||||
// Falsifiability control for the test above: the SAME blob with a jsonPath must validate clean, so
|
|
||||||
// the failure above is provably the missing jsonPath and not the topic or the dataType.
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_JsonWithPath_ReturnsNull()
|
public void Validate_JsonWithPath_ReturnsNull()
|
||||||
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Json","jsonPath":"$","dataType":"Float64"}""")
|
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Json","jsonPath":"$","dataType":"Float64"}""")
|
||||||
|
|||||||
Reference in New Issue
Block a user