|
1 | 1 | using System.Text.Json; |
| 2 | +using System.Text.Json.Nodes; |
2 | 3 | using System.Text.Json.Serialization; |
3 | 4 |
|
4 | 5 | namespace UnifiSharp.Legacy; |
@@ -73,6 +74,64 @@ public Task<UnifiNetwork> CreateNetworkAsync(UnifiNetwork spec, CancellationToke |
73 | 74 | public Task DeleteNetworkAsync(string id, CancellationToken ct = default) |
74 | 75 | => DeleteAsync("networkconf", id, ct); |
75 | 76 |
|
| 77 | + // NOTE there is no UpdateNetworkAsync(UnifiNetwork), deliberately. UnifiNetwork models |
| 78 | + // ~12 of a live network's ~63 fields and rest/networkconf/{id} is a full replacement, |
| 79 | + // so a typed update would silently reset the rest. Updates go through the raw trio |
| 80 | + // below: read the whole object, change named keys (e.g. NetworkDhcpDns.With), put it back. |
| 81 | + |
| 82 | + /// <summary>Every network as the controller sent it — no field dropped, no number re-formatted.</summary> |
| 83 | + public Task<IReadOnlyList<JsonObject>> ListNetworksRawAsync(CancellationToken ct = default) |
| 84 | + => ListRawAsync("networkconf", ct); |
| 85 | + |
| 86 | + /// <summary> |
| 87 | + /// One network as the controller sent it. Throws unless the controller returns exactly |
| 88 | + /// one row — zero or several is not "the" network, and a write built on it would be a guess. |
| 89 | + /// </summary> |
| 90 | + public async Task<JsonObject> GetNetworkRawAsync(string id, CancellationToken ct = default) |
| 91 | + { |
| 92 | + ThrowUnlessPlainSegment(id); |
| 93 | + using var resp = await _session.SendAsync( |
| 94 | + HttpMethod.Get, $"rest/networkconf/{id}", content: null, ct).ConfigureAwait(false); |
| 95 | + var rows = RawRows(await ReadEnvelopeAsync<JsonObject?>(resp, "networkconf", ct).ConfigureAwait(false), "networkconf"); |
| 96 | + return rows.Count == 1 |
| 97 | + ? rows[0] |
| 98 | + : throw new UnifiLegacyException($"networkconf/{id}: expected exactly one network, got {rows.Count}"); |
| 99 | + } |
| 100 | + |
| 101 | + /// <summary> |
| 102 | + /// Replace a network wholesale with <paramref name="body"/>, sent verbatim. Pass an object |
| 103 | + /// read by <see cref="GetNetworkRawAsync"/> and changed in place of named keys only; the |
| 104 | + /// caller's object is not modified. Returns the controller's echo of the network, or a copy |
| 105 | + /// of <paramref name="body"/> when the controller answers ok with no row — re-read to confirm. |
| 106 | + /// <para>"Verbatim" is per value, not per byte, for strings: numbers keep their original text |
| 107 | + /// and keys their order, but a string is re-escaped on write, so <c>"caf\u00e9"</c> read in |
| 108 | + /// may go out as <c>"café"</c> (same value, different bytes).</para> |
| 109 | + /// </summary> |
| 110 | + /// <exception cref="ArgumentException"><paramref name="id"/> is not a plain path segment, or |
| 111 | + /// <paramref name="body"/>'s <c>_id</c> is missing, not a string, or not <paramref name="id"/>.</exception> |
| 112 | + public async Task<JsonObject> ReplaceNetworkRawAsync(string id, JsonObject body, CancellationToken ct = default) |
| 113 | + { |
| 114 | + ThrowUnlessPlainSegment(id); |
| 115 | + ArgumentNullException.ThrowIfNull(body); |
| 116 | + // The PUT replaces the network at {id} with whatever body says, so a body read from |
| 117 | + // another network would silently overwrite this one with it. Missing counts as mismatch. |
| 118 | + var bodyId = body.TryGetPropertyValue("_id", out var idNode) && idNode?.GetValueKind() is JsonValueKind.String |
| 119 | + ? idNode.GetValue<string>() |
| 120 | + : null; |
| 121 | + if (bodyId != id) |
| 122 | + { |
| 123 | + throw new ArgumentException( |
| 124 | + $"body._id is {(bodyId is null ? "missing or not a string" : $"'{bodyId}'")}, expected '{id}'.", nameof(body)); |
| 125 | + } |
| 126 | + |
| 127 | + // ToJsonString writes parsed numbers from their original text, so 1.50 stays 1.50. |
| 128 | + using var resp = await _session.SendAsync( |
| 129 | + HttpMethod.Put, $"rest/networkconf/{id}", UnifiLegacySession.JsonBody(body.ToJsonString()), ct).ConfigureAwait(false); |
| 130 | + var rows = RawRows(await ReadEnvelopeAsync<JsonObject?>(resp, "networkconf", ct).ConfigureAwait(false), "networkconf"); |
| 131 | + // Same echo-on-empty as UpdateAsync, but cloned so the caller's object stays theirs. |
| 132 | + return rows.Count > 0 ? rows[0] : body.DeepClone().AsObject(); |
| 133 | + } |
| 134 | + |
76 | 135 | // ── Known clients / DHCP reservations (rest/user) ───────────────────────── |
77 | 136 | // NOTE there is no Delete here, deliberately. Removing a known client discards |
78 | 137 | // its name and history along with the reservation; retiring a reservation is |
@@ -171,6 +230,31 @@ private async Task<IReadOnlyList<T>> ListAsync<T>(string resource, CancellationT |
171 | 230 | return (await ReadEnvelopeAsync<T>(resp, resource, ct).ConfigureAwait(false)).Data; |
172 | 231 | } |
173 | 232 |
|
| 233 | + private async Task<IReadOnlyList<JsonObject>> ListRawAsync(string resource, CancellationToken ct) |
| 234 | + { |
| 235 | + using var resp = await _session.SendAsync(HttpMethod.Get, $"rest/{resource}", content: null, ct).ConfigureAwait(false); |
| 236 | + return RawRows(await ReadEnvelopeAsync<JsonObject?>(resp, resource, ct).ConfigureAwait(false), resource); |
| 237 | + } |
| 238 | + |
| 239 | + // A null row is a shape we don't recognise, not an empty object — refuse it rather |
| 240 | + // than hand back something a caller could write over a real network. Likewise "data":null: |
| 241 | + // the deserializer leaves Data null despite its type, and that is not "no rows". |
| 242 | + private static IReadOnlyList<JsonObject> RawRows(UnifiLegacyEnvelope<JsonObject?> env, string resource) => |
| 243 | + (env.Data ?? throw new UnifiLegacyException($"{resource}: response data is null")) |
| 244 | + .Select(r => r ?? throw new UnifiLegacyException($"{resource}: response row is null")) |
| 245 | + .ToList(); |
| 246 | + |
| 247 | + // The id is interpolated into the path, so "a/../x" or "a?b" would address something else. |
| 248 | + // Controller ids are hex ObjectIds; anything percent-encoding would change is refused, not escaped. |
| 249 | + private static void ThrowUnlessPlainSegment(string id, [System.Runtime.CompilerServices.CallerArgumentExpression(nameof(id))] string? paramName = null) |
| 250 | + { |
| 251 | + ArgumentException.ThrowIfNullOrEmpty(id, paramName); |
| 252 | + if (id is "." or ".." || Uri.EscapeDataString(id) != id) |
| 253 | + { |
| 254 | + throw new ArgumentException($"'{id}' is not a plain path segment.", paramName); |
| 255 | + } |
| 256 | + } |
| 257 | + |
174 | 258 | private async Task<T> CreateAsync<T>(string resource, T spec, CancellationToken ct) |
175 | 259 | { |
176 | 260 | var json = JsonSerializer.Serialize(spec, SerializerOptions); |
|
0 commit comments