Compare commits
250 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd7ca1634e | |||
| bdccdbf6dd | |||
| fa491c752b | |||
| aba228f443 | |||
| 5e493484f1 | |||
| 3e22285f09 | |||
| 120cd0b1b6 | |||
| 56949c967b | |||
| 7dec9b30f5 | |||
| 1d3c8edb44 | |||
| 58259016b0 | |||
| 864b9f4bd3 | |||
| de58872435 | |||
| 6777d49030 | |||
| 1b6ca07bb5 | |||
| 1ad0be8276 | |||
| 9328c4f657 | |||
| 0361dc1817 | |||
| ac12c150c3 | |||
| 40ca4b6908 | |||
| bf73985481 | |||
| 0a54fa5e35 | |||
| cec84bf572 | |||
| 099d4783b0 | |||
| c1fe7fbc4a | |||
| b39848b5f5 | |||
| 6126099cdb | |||
| c1ff8c94e8 | |||
| b794c46bc7 | |||
| 84d36b7638 | |||
| 1aafd6bde4 | |||
| a0203503a7 | |||
| 1cd51bbda3 | |||
| 61644e63fb | |||
| 7db4bffa30 | |||
| 93633ce99c | |||
| eaa7093cd6 | |||
| f220908f3f | |||
| 5e375f6d3d | |||
| 758aca2355 | |||
| 06030dd1ef | |||
| e355a7674b | |||
| cd92048f4e | |||
| 964b40dcbc | |||
| bb5603b7ec | |||
| 24de7e21d9 | |||
| ee959e46e6 | |||
| 771229b39f | |||
| a7bf1ef95d | |||
| b4f5e8eb48 | |||
| 371bcb3f91 | |||
| 9582de077b | |||
| bd3096533d | |||
| 6eb9ea9105 | |||
| 555fe4c0ba | |||
| 89043cb2b6 | |||
| 1764eff1cf | |||
| fe9044115b | |||
| a02faa6ade | |||
| 1f546c46ee | |||
| 6a4833bd32 | |||
| e4fbbb541a | |||
| f13f35bc79 | |||
| 18ce2922e2 | |||
| 5ade3f4f48 | |||
| 98f9b7792b | |||
| ff41556b9a | |||
| f88a029ecc | |||
| 8023eccfa6 | |||
| 54325343bd | |||
| 1d9e3afadd | |||
| 5e795aeeb8 | |||
| 1b4dcf32d5 | |||
| 53e3973209 | |||
| e967e85973 | |||
| bc55396334 | |||
| b381bfcaf1 | |||
| 2a635c8522 | |||
| 9082e504a9 | |||
| 0d8a28d2fe | |||
| f0a4af62b9 | |||
| a8aafdf974 | |||
| 3cc53a8c69 | |||
| ae164ea34f | |||
| 6c640306e5 | |||
| a67a5a4857 | |||
| e00ee61cf0 | |||
| 271bf7edff | |||
| 3397e99783 | |||
| f598b3a647 | |||
| 509b0118d4 | |||
| 298836d2f3 | |||
| 96bea1d478 | |||
| a4ed605f74 | |||
| 4e02927f01 | |||
| 47b1fd422c | |||
| 9b21ca3554 | |||
| 01f5e6ad91 | |||
| 82eb0ad569 | |||
| f711a55be4 | |||
| f490ae2593 | |||
| 39f9fd8946 | |||
| bb7be14d1d | |||
| 8ac6642bf8 | |||
| 4e8928cf71 | |||
| f4423dfb6d | |||
| 3ff4969224 | |||
| 12881ca791 | |||
| 6e356da092 | |||
| a739fadb5f | |||
| 6b3c117d1e | |||
| c7d5b83390 | |||
| 1ac5bcafb2 | |||
| e7c2c546b5 | |||
| a14098468b | |||
| e030661c1b | |||
| 4e933802a7 | |||
| 6c3edf4516 | |||
| 9de2c0c43d | |||
| bc61598b44 | |||
| 335c952f00 | |||
| 3256733d24 | |||
| 4f0f03fca5 | |||
| 9ca200f814 | |||
| fe19c478c0 | |||
| d0bc78cd43 | |||
| 730fdc93e0 | |||
| 55470e3e09 | |||
| b4016e738c | |||
| 10004879f6 | |||
| 168bb9a39a | |||
| a7edc8f8bf | |||
| 0765eb4de3 | |||
| 26d0e2c471 | |||
| 65d83b1400 | |||
| 7b621e3f64 | |||
| 0f88a953d7 | |||
| ddad573b75 | |||
| 8d3352f2c6 | |||
| eed1e88a37 | |||
| 4731ab535c | |||
| 51a9dadf62 | |||
| 133c83029b | |||
| 047d875fe6 | |||
| b0041c5d18 | |||
| 907aa49aea | |||
| 4fc355b357 | |||
| bd4a09a35e | |||
| d431ff9660 | |||
| 3d11ac3316 | |||
| daff16cfd2 | |||
| 6ce61a4f77 | |||
| 4ea2c4fd86 | |||
| 09e01de9c8 | |||
| 41a2d70f8f | |||
| 79f73e04fd | |||
| f2118f7028 | |||
| 9159f6f093 | |||
| d6939432f9 | |||
| 02143ef7e2 | |||
| c032852065 | |||
| 1d93e77234 | |||
| 0a670eb381 | |||
| b57662aae7 | |||
| 14afb325c3 | |||
| af42891d5a | |||
| 01a51df053 | |||
| 89a8fb876a | |||
| c58358fad9 | |||
| 8d312a6d2e | |||
| f861a8b3b8 | |||
| 499708b2a2 | |||
| 191b724f95 | |||
| 8793011838 | |||
| b275eedb44 | |||
| a9ef6d10d4 | |||
| 0f17a1d1d9 | |||
| 160343aff4 | |||
| 8ef98b8beb | |||
| f049d3e603 | |||
| ee88f9d647 | |||
| 6e34efd1a5 | |||
| 01d6c33156 | |||
| ec4e2f687e | |||
| f7929cc12f | |||
| d890eff862 | |||
| 9dcd4baff2 | |||
| 7a0743496f | |||
| bcfbd1cfc8 | |||
| 8e3b0c1c4a | |||
| bd4be85f26 | |||
| 7331c6157a | |||
| cbc317e3e7 | |||
| 7242cf772b | |||
| 7d67313a7d | |||
| 044b16c5db | |||
| 1f92078777 | |||
| 4a3560c7ee | |||
| 108a3d3f8a | |||
| 95e71cd819 | |||
| 647fe9a4b5 | |||
| dd455089b4 | |||
| d0bc4e3c01 | |||
| 6a40d26366 | |||
| 366f57198f | |||
| aab41e04ab | |||
| 3be92a17bd | |||
| a871f2f2e5 | |||
| 7b86bab705 | |||
| 56886c3b4e | |||
| a3ccd5c80b | |||
| 0fd954d94c | |||
| 91f2d8dc14 | |||
| fb425da009 | |||
| c7e4c4b614 | |||
| 59c710d789 | |||
| 862f119b91 | |||
| 35e4442c7b | |||
| ed1018c3bb | |||
| 2e4ba11a9f | |||
| ff86b3f0b0 | |||
| 653f17c669 | |||
| 556c3bfa83 | |||
| 9b3637257c | |||
| 77eac95f33 | |||
| 015fa1f50d | |||
| dede407304 | |||
| 0d96963c99 | |||
| 3661420f0a | |||
| 14419853c7 | |||
| a20517f5ad | |||
| 626e7762d9 | |||
| 8d6d3f6188 | |||
| 276288ad87 | |||
| 76bd3de5a2 | |||
| 29455fc1f6 | |||
| 5511609880 | |||
| 451dccf7e3 | |||
| cde9c89386 | |||
| d496f1fd75 | |||
| 6559672fc1 | |||
| 97c30b9d00 | |||
| 603aff7004 | |||
| e81682e367 | |||
| d5a982152b | |||
| 0b0be7098e | |||
| fce9e99553 | |||
| c8fb3e91a3 | |||
| 8ce327e6f4 | |||
| fad0ac9948 |
@@ -1,481 +0,0 @@
|
|||||||
# MXAccess Gateway Agent Guide
|
|
||||||
|
|
||||||
Repository: https://gitea.dohertylan.com/dohertj2/mxaccessgw
|
|
||||||
|
|
||||||
This project builds a gateway that gives modern clients full MXAccess parity
|
|
||||||
without requiring those clients to load MXAccess COM, run as x86, or own an STA
|
|
||||||
message pump. Treat the installed MXAccess COM component as the compatibility
|
|
||||||
baseline.
|
|
||||||
|
|
||||||
Toolchain paths, versions, and external analysis locations are recorded in
|
|
||||||
`docs/toolchain-links.md`. Use that file before searching for compilers,
|
|
||||||
runtimes, protobuf tools, MXAccess notes, or Galaxy Repository SQL notes.
|
|
||||||
|
|
||||||
Implementation planning is recorded in `docs/implementation-plan-index.md`.
|
|
||||||
Follow the order there unless the user explicitly reprioritizes: gateway first,
|
|
||||||
MXAccess worker instance second, clients third.
|
|
||||||
|
|
||||||
## Core Contract
|
|
||||||
|
|
||||||
Preserve MXAccess behavior first:
|
|
||||||
|
|
||||||
- public MXAccess command semantics,
|
|
||||||
- native MXAccess event families,
|
|
||||||
- STA/message-pump delivery behavior,
|
|
||||||
- installed-provider quirks,
|
|
||||||
- HRESULT/status/value marshaling,
|
|
||||||
- per-client isolation.
|
|
||||||
|
|
||||||
Do not simplify, normalize, or "fix" MXAccess behavior unless an explicit
|
|
||||||
non-parity mode is being implemented and tested. `MxAsbClient` and managed NMX
|
|
||||||
are future acceleration paths only; they do not define the parity contract.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
The intended split is:
|
|
||||||
|
|
||||||
```text
|
|
||||||
client
|
|
||||||
-> gRPC over TCP
|
|
||||||
-> .NET 10 x64 gateway
|
|
||||||
-> session manager
|
|
||||||
-> per-session .NET Framework 4.8 x86 worker process
|
|
||||||
-> dedicated STA thread
|
|
||||||
-> MXAccess COM instance
|
|
||||||
-> Windows/COM message pump
|
|
||||||
-> command queue
|
|
||||||
-> event sink
|
|
||||||
```
|
|
||||||
|
|
||||||
The gateway must never instantiate or call MXAccess directly. All MXAccess COM
|
|
||||||
interaction belongs in the worker process on its dedicated STA thread.
|
|
||||||
|
|
||||||
The worker must not host public gRPC. Gateway-to-worker communication should use
|
|
||||||
a small local IPC protocol, with named pipes and protobuf-framed messages as the
|
|
||||||
default design.
|
|
||||||
|
|
||||||
## Runtime Targets
|
|
||||||
|
|
||||||
- Gateway: .NET 10, C#, x64 preferred, ASP.NET Core gRPC.
|
|
||||||
- Worker: .NET Framework 4.8, C#, x86 by default.
|
|
||||||
- Worker IPC: one bidirectional named pipe per worker.
|
|
||||||
- Worker process model: one external client session maps to one worker by
|
|
||||||
default.
|
|
||||||
|
|
||||||
## Style Guides
|
|
||||||
|
|
||||||
Follow the project documentation guide and the language guide for every changed
|
|
||||||
area:
|
|
||||||
|
|
||||||
| Area | Style guide |
|
|
||||||
|------|-------------|
|
|
||||||
| Documentation | `StyleGuide.md` |
|
|
||||||
| Gateway, worker, .NET client, and C# tests | `docs/style-guides/CSharpStyleGuide.md` |
|
|
||||||
| Public gRPC and worker IPC contracts | `docs/style-guides/ProtobufStyleGuide.md` |
|
|
||||||
| Go client | `docs/style-guides/GoStyleGuide.md` |
|
|
||||||
| Rust client | `docs/style-guides/RustStyleGuide.md` |
|
|
||||||
| Python client | `docs/style-guides/PythonStyleGuide.md` |
|
|
||||||
| Java client | `docs/style-guides/JavaStyleGuide.md` |
|
|
||||||
|
|
||||||
When a change crosses languages, apply every affected style guide. Generated
|
|
||||||
code follows its generator output; do not hand-edit it to match handwritten
|
|
||||||
style.
|
|
||||||
|
|
||||||
## Expected Layout
|
|
||||||
|
|
||||||
Prefer this structure unless there is a strong reason to adjust it:
|
|
||||||
|
|
||||||
```text
|
|
||||||
src/MxGateway.Contracts/
|
|
||||||
Protos/
|
|
||||||
mxaccess_gateway.proto
|
|
||||||
mxaccess_worker.proto
|
|
||||||
Generated/
|
|
||||||
|
|
||||||
src/MxGateway.Server/
|
|
||||||
Program.cs
|
|
||||||
Sessions/
|
|
||||||
Workers/
|
|
||||||
Grpc/
|
|
||||||
Dashboard/
|
|
||||||
Metrics/
|
|
||||||
|
|
||||||
src/MxGateway.Worker/
|
|
||||||
Program.cs
|
|
||||||
Ipc/
|
|
||||||
Sta/
|
|
||||||
MxAccess/
|
|
||||||
Conversion/
|
|
||||||
|
|
||||||
src/MxGateway.Tests/
|
|
||||||
contract tests
|
|
||||||
gateway session tests
|
|
||||||
fake worker tests
|
|
||||||
|
|
||||||
src/MxGateway.Worker.Tests/
|
|
||||||
value/status conversion tests
|
|
||||||
STA queue tests
|
|
||||||
|
|
||||||
src/MxGateway.IntegrationTests/
|
|
||||||
optional live MXAccess tests
|
|
||||||
|
|
||||||
clients/dotnet/
|
|
||||||
.NET 10 C# client library, test CLI, and tests
|
|
||||||
|
|
||||||
clients/go/
|
|
||||||
Go client module, test CLI, and tests
|
|
||||||
|
|
||||||
clients/rust/
|
|
||||||
Rust client crate, test CLI, and tests
|
|
||||||
|
|
||||||
clients/python/
|
|
||||||
Python client package, test CLI, and tests
|
|
||||||
|
|
||||||
clients/java/
|
|
||||||
Java client library, test CLI, and tests
|
|
||||||
```
|
|
||||||
|
|
||||||
The contracts project may multi-target, or the `.proto` files may be shared as
|
|
||||||
source inputs to both gateway and worker builds.
|
|
||||||
|
|
||||||
## Public API Shape
|
|
||||||
|
|
||||||
The external API should be session-oriented. Initial rollout should prefer
|
|
||||||
unary `OpenSession`, `CloseSession`, and `Invoke`, plus server-streaming
|
|
||||||
`StreamEvents`. Add a bidirectional `Session` stream after the command and event
|
|
||||||
model is stable.
|
|
||||||
|
|
||||||
Do not compress MXAccess into generic verbs too early. Use a command enum with
|
|
||||||
method-specific payloads so parity can be tested method by method.
|
|
||||||
|
|
||||||
Core MXAccess commands to represent:
|
|
||||||
|
|
||||||
- `Register`
|
|
||||||
- `Unregister`
|
|
||||||
- `AddItem`
|
|
||||||
- `AddItem2`
|
|
||||||
- `RemoveItem`
|
|
||||||
- `Advise`
|
|
||||||
- `UnAdvise`
|
|
||||||
- `AdviseSupervisory`
|
|
||||||
- `AddBufferedItem`
|
|
||||||
- `SetBufferedUpdateInterval`
|
|
||||||
- `Suspend`
|
|
||||||
- `Activate`
|
|
||||||
- `Write`
|
|
||||||
- `Write2`
|
|
||||||
- `WriteSecured`
|
|
||||||
- `WriteSecured2`
|
|
||||||
- `AuthenticateUser`
|
|
||||||
- `ArchestrAUserToId`
|
|
||||||
|
|
||||||
Diagnostics may include `Ping`, `GetSessionState`, `GetWorkerInfo`,
|
|
||||||
`DrainEvents`, and `ShutdownWorker`.
|
|
||||||
|
|
||||||
## Event Requirements
|
|
||||||
|
|
||||||
Represent every public MXAccess event family:
|
|
||||||
|
|
||||||
- `OnDataChange`
|
|
||||||
- `OnWriteComplete`
|
|
||||||
- `OperationComplete`
|
|
||||||
- `OnBufferedDataChange`
|
|
||||||
|
|
||||||
Preserve per-worker event order. The gateway must not reorder events emitted by
|
|
||||||
the same MXAccess instance.
|
|
||||||
|
|
||||||
Event DTOs should carry event family, session id, server handle, item handle,
|
|
||||||
value, quality, timestamp, `MXSTATUS_PROXY[]` equivalent, raw HRESULT/status
|
|
||||||
fields when available, event sequence, worker timestamp, and gateway receive
|
|
||||||
timestamp.
|
|
||||||
|
|
||||||
## Value And Status Rules
|
|
||||||
|
|
||||||
Use a protobuf value union that can represent COM `VARIANT` values and arrays.
|
|
||||||
When a value cannot be losslessly converted, preserve both the best typed
|
|
||||||
projection and enough raw diagnostic metadata to reproduce the case.
|
|
||||||
|
|
||||||
Represent `MXSTATUS_PROXY` explicitly. Do not collapse status arrays into a
|
|
||||||
single success flag.
|
|
||||||
|
|
||||||
Command replies should include protocol status, COM HRESULT if available,
|
|
||||||
MXAccess return values, method-specific out parameters, and status arrays where
|
|
||||||
the MXAccess method emits them.
|
|
||||||
|
|
||||||
## Galaxy Repository SQL Discovery
|
|
||||||
|
|
||||||
Galaxy tags, hierarchy, and attribute details can be queried from the AVEVA /
|
|
||||||
Wonderware System Platform Galaxy Repository SQL Server database. Use this as a
|
|
||||||
discovery and metadata path only; runtime MXAccess parity still belongs to the
|
|
||||||
MXAccess-backed worker unless an explicit non-parity backend is being designed.
|
|
||||||
|
|
||||||
Full notes, schema details, screenshots, and query examples are in:
|
|
||||||
|
|
||||||
```text
|
|
||||||
C:\Users\dohertj2\Desktop\lmxopcua\gr
|
|
||||||
```
|
|
||||||
|
|
||||||
Important files in that notes directory:
|
|
||||||
|
|
||||||
- `connectioninfo.md` - SQL Server connection details and `sqlcmd` usage.
|
|
||||||
- `layout.md` - hierarchy vs `tag_name` relationship.
|
|
||||||
- `build_layout_plan.md` - extraction plan for hierarchy and attributes.
|
|
||||||
- `schema.md` and `ddl/` - Galaxy Repository schema reference.
|
|
||||||
- `queries/hierarchy.sql` - deployed object hierarchy.
|
|
||||||
- `queries/attributes.sql` - user-defined dynamic attributes.
|
|
||||||
- `queries/attributes_extended.sql` - system plus user-defined attributes.
|
|
||||||
- `queries/change_detection.sql` - deployment-change polling via
|
|
||||||
`galaxy.time_of_last_deploy`.
|
|
||||||
|
|
||||||
Current documented connection is SQL Server `localhost`, database `ZB`, Windows
|
|
||||||
Auth. Example:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
sqlcmd -S localhost -d ZB -E -Q "SELECT time_of_last_deploy FROM galaxy;"
|
|
||||||
```
|
|
||||||
|
|
||||||
Key tables from the notes are `gobject`, `template_definition`,
|
|
||||||
`dynamic_attribute`, `attribute_definition`, `primitive_instance`, and
|
|
||||||
`galaxy`. The hierarchy uses contained names for human-readable browsing, while
|
|
||||||
runtime tag references use globally unique `tag_name` values such as
|
|
||||||
`<tag_name>.<AttributeName>`.
|
|
||||||
|
|
||||||
## MXAccess Analysis Source
|
|
||||||
|
|
||||||
Use the local MXAccess analysis project when answering questions about installed
|
|
||||||
MXAccess classes, interfaces, fields, events, HRESULT/status behavior, value
|
|
||||||
projection, captures, and parity gaps:
|
|
||||||
|
|
||||||
```text
|
|
||||||
C:\Users\dohertj2\Desktop\mxaccess
|
|
||||||
```
|
|
||||||
|
|
||||||
Primary files:
|
|
||||||
|
|
||||||
- `README.md` - overview of available analysis and capture artifacts.
|
|
||||||
- `docs/MXAccess-Public-API.md` - COM class, ProgID, CLSID, method list,
|
|
||||||
event signatures, `MxDataType`, `MxStatus`, and `MXSTATUS_PROXY`.
|
|
||||||
- `docs/MXAccess-Reverse-Engineering.md` - installed runtime path and x86 COM
|
|
||||||
constraints.
|
|
||||||
- `docs/Current-Sprint-State.md` and `docs/DotNet10-Native-Library-Plan.md` -
|
|
||||||
current parity gaps and managed native-client research status.
|
|
||||||
- `src/MxTraceHarness/` - x86 MXAccess harness examples using the real COM
|
|
||||||
interop assembly.
|
|
||||||
- `captures/` and `analysis/` - observed native behavior and generated
|
|
||||||
reverse-engineering artifacts.
|
|
||||||
|
|
||||||
Concrete MXAccess COM target from the analysis:
|
|
||||||
|
|
||||||
- class: `ArchestrA.MxAccess.LMXProxyServerClass`
|
|
||||||
- CLSID: `{C30B52F5-2CB5-4760-AF0A-3A344A7EB5DC}`
|
|
||||||
- ProgID: `LMXProxy.LMXProxyServer.1`
|
|
||||||
- version-independent ProgID: `LMXProxy.LMXProxyServer`
|
|
||||||
- registered server: `C:\Program Files (x86)\ArchestrA\Framework\Bin\LmxProxy.dll`
|
|
||||||
- interop assembly:
|
|
||||||
`C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`
|
|
||||||
- threading model: `Apartment`
|
|
||||||
|
|
||||||
## Worker Rules
|
|
||||||
|
|
||||||
Each worker owns:
|
|
||||||
|
|
||||||
- one process,
|
|
||||||
- one MXAccess session,
|
|
||||||
- one dedicated STA thread,
|
|
||||||
- one MXAccess COM object,
|
|
||||||
- one inbound command queue,
|
|
||||||
- one outbound event queue.
|
|
||||||
|
|
||||||
All MXAccess operations must run on the STA. A plain blocking queue is not
|
|
||||||
enough for the STA; the STA loop must pump Windows/COM messages and service
|
|
||||||
queued commands.
|
|
||||||
|
|
||||||
Do not block the STA on pipe writes, gRPC calls, or slow consumers. Event
|
|
||||||
handlers should convert event args, enqueue outbound events, and return to
|
|
||||||
pumping messages.
|
|
||||||
|
|
||||||
On graceful shutdown, reject new commands, optionally clean up active MXAccess
|
|
||||||
handles, detach events, release the COM object, uninitialize COM, and exit. If
|
|
||||||
graceful shutdown exceeds the configured timeout, the gateway may kill the
|
|
||||||
worker.
|
|
||||||
|
|
||||||
## IPC Rules
|
|
||||||
|
|
||||||
Default pipe name shape:
|
|
||||||
|
|
||||||
```text
|
|
||||||
mxaccess-gateway-{gatewayProcessId}-{sessionId}
|
|
||||||
```
|
|
||||||
|
|
||||||
Frame messages as:
|
|
||||||
|
|
||||||
```text
|
|
||||||
uint32 little-endian payload_length
|
|
||||||
payload_length bytes protobuf WorkerEnvelope
|
|
||||||
```
|
|
||||||
|
|
||||||
Every envelope should include protocol version, session id, monotonic sender
|
|
||||||
sequence, correlation id, and a typed body. Protocol version mismatch should
|
|
||||||
fail session creation.
|
|
||||||
|
|
||||||
Pipe security should be local-machine only, with ACLs restricted to the gateway
|
|
||||||
identity and launched worker identity. Prefer a per-session nonce handshake.
|
|
||||||
|
|
||||||
## Gateway Rules
|
|
||||||
|
|
||||||
The gateway is responsible for:
|
|
||||||
|
|
||||||
- public TCP/gRPC API,
|
|
||||||
- Blazor Server dashboard using Bootstrap CSS/JS only,
|
|
||||||
- authn/authz when needed,
|
|
||||||
- session creation and teardown,
|
|
||||||
- worker launch and lifecycle management,
|
|
||||||
- command routing,
|
|
||||||
- event streaming,
|
|
||||||
- leases, heartbeats, timeouts, and quotas,
|
|
||||||
- worker kill/restart policy,
|
|
||||||
- metrics and structured logs.
|
|
||||||
|
|
||||||
The gRPC layer should stay thin: validate request, find session, call the
|
|
||||||
session worker client, map worker replies to public replies, and stream events.
|
|
||||||
Keep MXAccess-specific translation logic testable outside the gRPC handlers.
|
|
||||||
|
|
||||||
Dashboard code should also stay thin and read-only for v1. Use a snapshot
|
|
||||||
service over session/worker/metrics state; do not let Razor components mutate
|
|
||||||
gateway sessions or workers directly. Do not use MudBlazor or other Blazor UI
|
|
||||||
component libraries.
|
|
||||||
|
|
||||||
Gateway restart should not try to reattach old workers in the first version.
|
|
||||||
Terminate orphaned workers on startup if that behavior is implemented.
|
|
||||||
|
|
||||||
## Command, Timeout, And Cancellation Semantics
|
|
||||||
|
|
||||||
Command lifecycle:
|
|
||||||
|
|
||||||
```text
|
|
||||||
client gRPC command
|
|
||||||
gateway validates session and payload
|
|
||||||
gateway assigns correlation id
|
|
||||||
gateway writes WorkerCommand to pipe
|
|
||||||
worker queues command to STA
|
|
||||||
STA executes MXAccess method
|
|
||||||
worker captures return/out/status/HRESULT
|
|
||||||
worker sends WorkerCommandReply
|
|
||||||
gateway completes gRPC response
|
|
||||||
```
|
|
||||||
|
|
||||||
Canceling a gRPC call should stop waiting in the gateway, but it cannot safely
|
|
||||||
abort an in-flight COM call on the STA. Hard cancellation means killing the
|
|
||||||
worker process.
|
|
||||||
|
|
||||||
If a command wedges the STA beyond a configured grace period, the gateway should
|
|
||||||
kill the worker and fail the session.
|
|
||||||
|
|
||||||
## Backpressure Policy
|
|
||||||
|
|
||||||
Worker outbound events must use a bounded queue. For parity testing, prefer
|
|
||||||
fail-fast behavior over silent drops. Production coalescing or drop policies
|
|
||||||
must be explicit and observable.
|
|
||||||
|
|
||||||
The gateway should preserve per-session event order, apply backpressure from
|
|
||||||
slow gRPC streams, and disconnect or coalesce only according to an explicit
|
|
||||||
policy.
|
|
||||||
|
|
||||||
## Security And Logging
|
|
||||||
|
|
||||||
Use TLS for remote gRPC when crossing machine boundaries. Authentication may be
|
|
||||||
Windows auth, mTLS, or a deployment-specific token.
|
|
||||||
|
|
||||||
Commands that write, authenticate users, or alter runtime state need explicit
|
|
||||||
authorization design.
|
|
||||||
|
|
||||||
Never log passwords or raw credential values for `AuthenticateUser`,
|
|
||||||
`WriteSecured`, or related secured operations. Do not log full values by
|
|
||||||
default; make value logging opt-in and redacted.
|
|
||||||
|
|
||||||
## Testing Expectations
|
|
||||||
|
|
||||||
Use focused tests for:
|
|
||||||
|
|
||||||
- contract/protobuf compatibility,
|
|
||||||
- gateway session state and worker lifecycle,
|
|
||||||
- gateway behavior with a fake worker,
|
|
||||||
- worker value/status conversion,
|
|
||||||
- STA queue and message-pump behavior.
|
|
||||||
|
|
||||||
Live MXAccess integration tests are optional but should be isolated because they
|
|
||||||
depend on installed COM components and provider behavior.
|
|
||||||
|
|
||||||
Parity tests should compare direct MXAccess behavior against the gateway:
|
|
||||||
|
|
||||||
- return values,
|
|
||||||
- HRESULTs and exceptions,
|
|
||||||
- event sequence,
|
|
||||||
- value projection,
|
|
||||||
- quality/status arrays,
|
|
||||||
- invalid handle behavior,
|
|
||||||
- cross-server handle behavior,
|
|
||||||
- cleanup behavior.
|
|
||||||
|
|
||||||
Known important parity areas:
|
|
||||||
|
|
||||||
- `WriteSecured` may fail before a value-bearing NMX body is emitted.
|
|
||||||
- `WriteSecured2` can succeed in observed native paths.
|
|
||||||
- `OperationComplete` is distinct from write completion.
|
|
||||||
- `OnBufferedDataChange` has a distinct public event shape.
|
|
||||||
- Invalid handles and cross-server handles have specific exception/status
|
|
||||||
behavior.
|
|
||||||
- STA message pumping is required for event delivery.
|
|
||||||
|
|
||||||
## Source Update Workflow
|
|
||||||
|
|
||||||
When source code changes, build the affected component before handing work
|
|
||||||
back. If the change crosses component boundaries, build each affected component
|
|
||||||
instead of relying on a single top-level build.
|
|
||||||
|
|
||||||
Use the native build and test command for each changed area:
|
|
||||||
|
|
||||||
| Changed area | Required verification |
|
|
||||||
|--------------|-----------------------|
|
|
||||||
| Contracts or `.proto` files | regenerate generated code, then build gateway, worker, and every generated client touched by the contract |
|
|
||||||
| Gateway server, sessions, workers, gRPC, dashboard, or metrics | build the .NET 10 gateway project and run affected gateway or fake-worker tests |
|
|
||||||
| Worker IPC, STA, MXAccess, or conversion code | build the .NET Framework 4.8 x86 worker project and run affected worker tests |
|
|
||||||
| Shared test infrastructure | run every test suite that consumes the changed helpers |
|
|
||||||
| .NET client | build the .NET client library, CLI, and tests |
|
|
||||||
| Go client | run Go formatting, build, and tests for the Go module |
|
|
||||||
| Rust client | run Rust formatting, build or check, and tests for the Rust crate |
|
|
||||||
| Python client | run Python formatting or linting if configured, package/build checks, and tests |
|
|
||||||
| Java client | build the Java client library, CLI, and tests |
|
|
||||||
| Integration tests | run them only when the required MXAccess COM component, provider state, and external services are available; otherwise document why they were skipped |
|
|
||||||
|
|
||||||
Update affected documentation in the same change as the source update. This
|
|
||||||
includes `gateway.md`, component design docs under `docs/`, client docs, API
|
|
||||||
contract notes, test instructions, and operational guidance. Documentation must
|
|
||||||
follow `StyleGuide.md`: write technical present-tense prose, explain the reason
|
|
||||||
for non-obvious choices, use exact code names, specify languages on code
|
|
||||||
blocks, use relative links for internal docs, and avoid stale temporary notes.
|
|
||||||
Source code and contract changes must also follow the relevant language guide
|
|
||||||
from the Style Guides section.
|
|
||||||
|
|
||||||
Do not leave documentation describing old behavior after changing public APIs,
|
|
||||||
contracts, configuration, build steps, security behavior, event shapes, value
|
|
||||||
conversion, status mapping, lifecycle rules, or client semantics.
|
|
||||||
|
|
||||||
## Implementation Priority
|
|
||||||
|
|
||||||
Build the smallest end-to-end slice first:
|
|
||||||
|
|
||||||
1. .NET 10 gateway starts.
|
|
||||||
2. Client calls `OpenSession`.
|
|
||||||
3. Gateway launches .NET Framework 4.8 x86 worker.
|
|
||||||
4. Worker creates STA and MXAccess COM object.
|
|
||||||
5. Client calls `Register`.
|
|
||||||
6. Client calls `AddItem`.
|
|
||||||
7. Client calls `Advise`.
|
|
||||||
8. Worker forwards one `OnDataChange` event to the gateway.
|
|
||||||
9. Gateway streams the event to the client.
|
|
||||||
10. Client calls `CloseSession`.
|
|
||||||
11. Gateway shuts down the worker.
|
|
||||||
|
|
||||||
That slice proves the high-risk requirements: process isolation, STA ownership,
|
|
||||||
message pumping, command routing, and event streaming.
|
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
`mxaccessgw` is the MXAccess Gateway: a gRPC service that gives modern (.NET, Go, Rust, Python, Java) clients full MXAccess parity without forcing them to load 32-bit MXAccess COM, run x86, or own an STA message pump.
|
||||||
|
|
||||||
|
The architecture is a two-process design — read `gateway.md` before making structural changes:
|
||||||
|
|
||||||
|
- **Gateway** (`src/MxGateway.Server`, .NET 10, x64): ASP.NET Core gRPC server. Owns the public API, sessions, auth, the Blazor dashboard, and the Galaxy Repository SQL browse RPCs. **Never instantiates MXAccess COM directly.**
|
||||||
|
- **Worker** (`src/MxGateway.Worker`, .NET Framework 4.8, **x86**): one process per session. Owns one MXAccess COM instance on a dedicated STA, pumps Windows messages, and converts COM events to protobuf.
|
||||||
|
- **IPC**: gateway↔worker uses one bidirectional named pipe per worker (`mxaccess-gateway-{gatewayPid}-{sessionId}`) with length-prefixed `WorkerEnvelope` protobuf frames. Gateway hosts the pipe server and launches the worker. **gRPC is not used inside the worker** — .NET Framework 4.8 doesn't have a first-class gRPC stack.
|
||||||
|
- **Contracts** (`src/MxGateway.Contracts`): multi-targets `net10.0;net48` and owns the `.proto` files (`mxaccess_gateway.proto`, `mxaccess_worker.proto`, `galaxy_repository.proto`). All other projects consume the generated types from here. Do not hand-edit anything under `Generated/`.
|
||||||
|
|
||||||
|
The worker must do all MXAccess COM calls on its dedicated STA thread, and the STA loop must pump Windows messages (`MsgWaitForMultipleObjectsEx` + `PeekMessage`/`DispatchMessage`) so MXAccess events deliver. A plain blocking queue on an STA is not enough.
|
||||||
|
|
||||||
|
## Build, Test, Run
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Full solution build (gateway, worker, contracts, tests)
|
||||||
|
dotnet build src/MxGateway.sln
|
||||||
|
|
||||||
|
# Worker must be built x86 — the gateway looks for MxGateway.Worker.exe under bin\x86
|
||||||
|
dotnet build src/MxGateway.Worker/MxGateway.Worker.csproj -p:Platform=x86
|
||||||
|
|
||||||
|
# Gateway tests (no MXAccess required — uses FakeWorkerHarness)
|
||||||
|
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj
|
||||||
|
dotnet test src/MxGateway.Worker.Tests/MxGateway.Worker.Tests.csproj -p:Platform=x86
|
||||||
|
|
||||||
|
# Run gateway locally (defaults bound under MxGateway:* in src/MxGateway.Server/appsettings.json)
|
||||||
|
dotnet run --project src/MxGateway.Server/MxGateway.Server.csproj
|
||||||
|
|
||||||
|
# API-key admin CLI (same exe, "apikey" subcommand)
|
||||||
|
dotnet run --project src/MxGateway.Server/MxGateway.Server.csproj -- apikey create --display-name "dev" --scopes session:open,session:close,invoke:read,invoke:write,invoke:secure,events:read,metadata:read,admin
|
||||||
|
```
|
||||||
|
|
||||||
|
Single test by name (xUnit `--filter`):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~GatewayEndToEndFakeWorkerSmokeTests
|
||||||
|
```
|
||||||
|
|
||||||
|
Live MXAccess integration tests are **opt-in** because they need installed MXAccess COM and live provider state:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:MXGATEWAY_RUN_LIVE_MXACCESS_TESTS = "1"
|
||||||
|
dotnet test src/MxGateway.IntegrationTests/MxGateway.IntegrationTests.csproj --filter FullyQualifiedName~WorkerLiveMxAccessSmokeTests
|
||||||
|
```
|
||||||
|
|
||||||
|
Live LDAP tests use `MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`. See `docs/GatewayTesting.md` for the full opt-in matrix and `LiveMxAccessFactAttribute` / `LiveLdapFactAttribute` for the gating logic.
|
||||||
|
|
||||||
|
## Clients
|
||||||
|
|
||||||
|
Each language client is in `clients/<lang>/` with its own README. They all consume the shared `.proto` files in `src/MxGateway.Contracts/Protos`:
|
||||||
|
|
||||||
|
- `clients/dotnet`: `dotnet build clients/dotnet/MxGateway.Client.sln`
|
||||||
|
- `clients/python`: `python -m pip install -e ".[dev]"; python -m pytest`
|
||||||
|
- `clients/rust`: `cargo test --workspace; cargo clippy --workspace --all-targets -- -D warnings`
|
||||||
|
- `clients/java`: `gradle test` (Java 21)
|
||||||
|
- Go client lives alongside as `mxgw-go` in the cross-language matrix
|
||||||
|
|
||||||
|
End-to-end matrix runner (needs running gateway + worker + valid API key):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:MXGATEWAY_API_KEY = "<api-key>"
|
||||||
|
powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Repository-Specific Conventions
|
||||||
|
|
||||||
|
- **Build properties** (`src/Directory.Build.props`) enforce `Nullable=enable`, `TreatWarningsAsErrors=true`, latest analyzers, and `EnforceCodeStyleInBuild=true`. New warnings break the build — fix them, don't suppress unless the suppression has a narrow reason.
|
||||||
|
- **Style guides** in `docs/style-guides/` are authoritative. Follow `CSharpStyleGuide.md` for gateway/worker/.NET-client code: file-scoped namespaces, `sealed` by default, `Async` suffix on Task-returning methods, MXAccess-aligned names (`MxStatusProxy`, `ServerHandle`, `ItemHandle`, `HResult`).
|
||||||
|
- **MXAccess parity is the contract.** Don't "fix" surprising MXAccess behavior (e.g., `WriteSecured` failing before a value-bearing NMX body, distinct `OperationComplete` semantics, invalid-handle exceptions) unless the client explicitly opts into a non-parity mode. The installed MXAccess COM component is the baseline.
|
||||||
|
- **Don't synthesize events.** The gateway forwards only events the worker emits; it never invents `OperationComplete` from write completion or command replies.
|
||||||
|
- **One worker per session, one event subscriber per session** (v1). Multi-subscriber fan-out and reconnectable sessions are explicitly out of scope — see `docs/DesignDecisions.md`.
|
||||||
|
- **Gateway restart does not reattach orphan workers.** The first version terminates orphaned workers on startup; do not design code paths that assume reattachment.
|
||||||
|
- **No Blazor UI component libraries.** Dashboard uses local Bootstrap CSS/JS only — do not introduce MudBlazor, Radzen, FluentUI, etc.
|
||||||
|
- **Don't log secrets or full tag values by default.** API keys, passwords, `WriteSecured` payloads, and `AuthenticateUser` credentials must never reach logs. Value logging is opt-in and redacted.
|
||||||
|
- **Generated code** under `src/MxGateway.Contracts/Generated/`, `clients/*/generated*/`, `clients/python/src/mxgateway/generated/`, etc., is build output. Don't hand-edit. To regenerate, build the contracts project (`dotnet build src/MxGateway.Contracts/MxGateway.Contracts.csproj`) or run the per-client generation step in that client's README.
|
||||||
|
- **Documentation style** (`StyleGuide.md`): PascalCase filenames, no marketing language, present tense, explain *why* not *what*.
|
||||||
|
- **Update docs in the same change as the source.** When public APIs, contracts, configuration, build steps, security behavior, event shapes, value conversion, status mapping, or lifecycle rules change, the affected docs (`gateway.md`, `docs/`, client READMEs, design docs) must change in the same commit. Don't leave stale prose describing old behavior.
|
||||||
|
|
||||||
|
## Source Update Workflow
|
||||||
|
|
||||||
|
When source code changes, build and test the affected component before reporting work done. If the change crosses component boundaries, build each affected component — don't rely on a single top-level build:
|
||||||
|
|
||||||
|
| Changed area | Required verification |
|
||||||
|
|---|---|
|
||||||
|
| Contracts or `.proto` files | regenerate generated code, then build gateway, worker, and every generated client touched by the contract |
|
||||||
|
| Gateway server, sessions, workers, gRPC, dashboard, metrics | `dotnet build src/MxGateway.Server` and run affected gateway / fake-worker tests |
|
||||||
|
| Worker IPC, STA, MXAccess, conversion | `dotnet build src/MxGateway.Worker -p:Platform=x86` and run worker tests |
|
||||||
|
| .NET client | `dotnet build clients/dotnet/MxGateway.Client.sln` and run its tests |
|
||||||
|
| Go client | `gofmt`, `go build ./...`, `go test ./...` from `clients/go` |
|
||||||
|
| Rust client | `cargo fmt`, `cargo check --workspace`, `cargo test --workspace`, `cargo clippy --all-targets -- -D warnings` from `clients/rust` |
|
||||||
|
| Python client | `python -m pytest` from `clients/python` |
|
||||||
|
| Java client | `gradle test` from `clients/java` |
|
||||||
|
| Integration tests | run only when MXAccess COM, provider state, and external services are available; otherwise document why skipped |
|
||||||
|
|
||||||
|
## Design Sources To Consult Before Non-Trivial Changes
|
||||||
|
|
||||||
|
- `gateway.md` — top-level architecture, command/event surface, IPC envelope, STA thread model, fault handling.
|
||||||
|
- `glauth.md` — local LDAP server (GLAuth on `localhost:3893`, base DN `dc=lmxopcua,dc=local`) used for dev authn. Pre-provisioned users (`admin/admin123`, `readonly/readonly123`, etc.) and the role→capability mapping live there.
|
||||||
|
- `docs/DesignDecisions.md` — v1 choices (MXAccess COM target `LMXProxyServerClass` from `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`, API-key-in-SQLite auth, fail-fast event backpressure, etc.).
|
||||||
|
- `docs/GatewayProcessDesign.md`, `docs/MxAccessWorkerInstanceDesign.md`, `docs/WorkerFrameProtocol.md`, `docs/WorkerProcessLauncher.md` — detailed component designs.
|
||||||
|
- `docs/GatewayConfiguration.md` — full `MxGateway:*` options bound by `GatewayOptions` and validated at startup by `GatewayOptionsValidator`.
|
||||||
|
- `docs/GatewayTesting.md` — fake worker harness, live MXAccess smoke, parity matrix, cross-language smoke matrix.
|
||||||
|
- `docs/ToolchainLinks.md` — installed compiler/SDK paths on this dev box (.NET 10.0.201, Go 1.26.2, Rust 1.95, Python 3.12.10, Temurin 21, protoc 34.1, etc.).
|
||||||
|
|
||||||
|
External analysis sources referenced by design docs:
|
||||||
|
|
||||||
|
- `C:\Users\dohertj2\Desktop\mxaccess` — MXAccess analysis project. Key files: `docs/MXAccess-Public-API.md` (COM class, ProgID, CLSID, method list, event signatures, `MxDataType`, `MxStatus`, `MXSTATUS_PROXY`), `docs/MXAccess-Reverse-Engineering.md` (installed runtime path, x86 COM constraints), `docs/Current-Sprint-State.md` (parity gaps), `src/MxTraceHarness/` (x86 harness using the real COM interop), `captures/` and `analysis/` (observed native behavior).
|
||||||
|
- `C:\Users\dohertj2\Desktop\lmxopcua\gr` — Galaxy Repository (`ZB` SQL DB) notes. Key files: `connectioninfo.md`, `layout.md`, `schema.md`, `queries/hierarchy.sql`, `queries/attributes.sql`, `queries/attributes_extended.sql`, `queries/change_detection.sql`. Connection is SQL Server `localhost`, database `ZB`, Windows Auth.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Gateway gRPC clients authenticate with an API key in metadata: `authorization: Bearer mxgw_<key-id>_<secret>`. Keys are stored hashed (with a peppered SHA) in a gateway-owned SQLite DB (default `C:\ProgramData\MxGateway\gateway-auth.db`). Scopes (`session:open`, `session:close`, `invoke:read`, `invoke:write`, `invoke:secure`, `events:read`, `metadata:read`, `admin`) gate specific RPCs; missing → `Unauthenticated`, insufficient → `PermissionDenied`. The `apikey` subcommand on the server exe manages keys; see `src/MxGateway.Server/Security/Authentication/`.
|
||||||
|
|
||||||
|
Dashboard auth uses the same verifier but exchanges the API key for an HTTP-only secure cookie at `/dashboard/login`. `Dashboard:AllowAnonymousLocalhost` bypasses cookie auth on loopback when explicitly enabled.
|
||||||
|
|
||||||
|
## Process / Platform Notes
|
||||||
|
|
||||||
|
- Working tree is on Windows (`C:\Users\dohertj2\Desktop\mxaccessgw`). PowerShell is the native shell for tooling commands; bash is fine for git/grep/find.
|
||||||
|
- The worker reference to `ArchestrA.MXAccess.dll` uses an absolute `HintPath` to `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`. The worker only builds where MXAccess is installed (this dev box).
|
||||||
|
- The repo is not a git repository at the top level — there's no `.git` directory in the working tree.
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# Code Review Process
|
||||||
|
|
||||||
|
This document describes how to perform a comprehensive, per-module code review of
|
||||||
|
the `mxaccessgw` codebase and how to track findings to resolution.
|
||||||
|
|
||||||
|
A **module** is one buildable project under `src/` (e.g. `src/MxGateway.Worker`)
|
||||||
|
or one language client under `clients/` (e.g. `clients/rust`). Each module has
|
||||||
|
its own folder under `code-reviews/` containing a single `findings.md`.
|
||||||
|
|
||||||
|
## 1. Before you start
|
||||||
|
|
||||||
|
1. Pick the module to review. Its folder is `code-reviews/<Module>/`:
|
||||||
|
- For a `src/` project, `<Module>` is the project name with the `MxGateway.`
|
||||||
|
prefix stripped — `src/MxGateway.Server` is reviewed in `code-reviews/Server/`.
|
||||||
|
- For a language client, `<Module>` is `Client.<Lang>` — `clients/rust` is
|
||||||
|
reviewed in `code-reviews/Client.Rust/`.
|
||||||
|
2. Identify the design context for the module:
|
||||||
|
- `gateway.md` — top-level architecture, command/event surface, IPC envelope,
|
||||||
|
STA thread model, fault handling.
|
||||||
|
- The relevant component design docs under `docs/` (e.g.
|
||||||
|
`docs/MxAccessWorkerInstanceDesign.md`, `docs/GatewayProcessDesign.md`,
|
||||||
|
`docs/Sessions.md`, `docs/Authentication.md`, `docs/GalaxyRepository.md`).
|
||||||
|
- `docs/DesignDecisions.md` for the v1 design choices.
|
||||||
|
- The **Repository-Specific Conventions** and **Process / Platform Notes** in
|
||||||
|
`CLAUDE.md`.
|
||||||
|
3. Record the exact commit being reviewed: `git rev-parse --short HEAD`. Every
|
||||||
|
review is a snapshot — a finding only means something relative to a known
|
||||||
|
commit.
|
||||||
|
4. Open `code-reviews/<Module>/findings.md` and fill in the header table
|
||||||
|
(reviewer, date, commit SHA, status).
|
||||||
|
|
||||||
|
## 2. Review checklist
|
||||||
|
|
||||||
|
Work through **every** category below for the module. A comprehensive review
|
||||||
|
means the checklist is completed even where it produces no findings — record
|
||||||
|
"No issues found" for a category rather than leaving it ambiguous.
|
||||||
|
|
||||||
|
1. **Correctness & logic bugs** — off-by-one, null handling, incorrect
|
||||||
|
conditionals, misuse of APIs, broken edge cases.
|
||||||
|
2. **mxaccessgw conventions** — the rules in `CLAUDE.md` and the style guides
|
||||||
|
under `docs/style-guides/`: the gateway never instantiates MXAccess COM
|
||||||
|
directly; all MXAccess COM calls run on the worker's dedicated STA thread and
|
||||||
|
the STA loop pumps Windows messages; IPC uses one bidirectional named pipe per
|
||||||
|
worker carrying length-prefixed `WorkerEnvelope` protobuf frames; MXAccess
|
||||||
|
parity is the contract (don't "fix" surprising MXAccess behaviour, never
|
||||||
|
synthesize events); one worker and one event subscriber per session; the
|
||||||
|
gateway terminates orphan workers on startup and does not reattach; C# style
|
||||||
|
(file-scoped namespaces, `sealed` by default, `Async` suffix, MXAccess-aligned
|
||||||
|
names); no Blazor UI component libraries; no logging of secrets or full tag
|
||||||
|
values; generated code is never hand-edited.
|
||||||
|
3. **Concurrency & thread safety** — shared mutable state, STA affinity, race
|
||||||
|
conditions, correct use of `async`/`await`, locking, disposal races.
|
||||||
|
4. **Error handling & resilience** — exception paths, worker crash / reconnect
|
||||||
|
handling, fail-fast event backpressure, transient vs permanent error
|
||||||
|
classification, graceful degradation, correct gRPC status codes.
|
||||||
|
5. **Security** — authentication/authorization checks, API-key scope enforcement,
|
||||||
|
input validation, SQL injection in the Galaxy Repository RPCs, secret
|
||||||
|
handling, the dashboard anonymous-localhost bypass, logging of sensitive data.
|
||||||
|
6. **Performance & resource management** — `IDisposable` disposal, pipe / stream
|
||||||
|
/ COM lifetimes, buffering and back-pressure, unnecessary allocations on hot
|
||||||
|
paths, N+1 queries.
|
||||||
|
7. **Design-document adherence** — does the code match `gateway.md`, the relevant
|
||||||
|
`docs/` component designs, `docs/DesignDecisions.md`, and `CLAUDE.md`? Flag
|
||||||
|
both code that drifts from the design and design docs that are now stale.
|
||||||
|
8. **Code organization & conventions** — namespace hierarchy, project layout, the
|
||||||
|
Options pattern, separation of concerns, additive-only contract evolution.
|
||||||
|
9. **Testing coverage** — are the module's behaviours covered by tests
|
||||||
|
(`src/MxGateway.Tests`, `src/MxGateway.Worker.Tests`,
|
||||||
|
`src/MxGateway.IntegrationTests`)? Note untested critical paths and missing
|
||||||
|
edge-case tests.
|
||||||
|
10. **Documentation & comments** — XML doc accuracy, misleading or stale comments,
|
||||||
|
undocumented non-obvious behaviour.
|
||||||
|
|
||||||
|
## 3. Recording findings
|
||||||
|
|
||||||
|
Add one entry per finding to the `## Findings` section of the module's
|
||||||
|
`findings.md`, using the entry format in
|
||||||
|
[`_template/findings.md`](code-reviews/_template/findings.md).
|
||||||
|
|
||||||
|
- **Finding ID** — `<Module>-NNN`, numbered sequentially within the module and
|
||||||
|
never reused (e.g. `Worker-001`). IDs are permanent even after resolution.
|
||||||
|
- **Severity:**
|
||||||
|
- **Critical** — data loss, security breach, crash/deadlock, or outage.
|
||||||
|
- **High** — incorrect behaviour with significant impact; no safe workaround.
|
||||||
|
- **Medium** — incorrect or risky behaviour with limited impact or a workaround.
|
||||||
|
- **Low** — minor issues, style, maintainability, documentation.
|
||||||
|
- **Category** — one of the 10 checklist categories above.
|
||||||
|
- **Location** — `file:line` (clickable), or a list of locations.
|
||||||
|
- **Description** — what is wrong and why it matters.
|
||||||
|
- **Recommendation** — concrete suggested fix.
|
||||||
|
|
||||||
|
After recording findings, update the module header table (status, open-finding
|
||||||
|
count) and regenerate the base README (step 5).
|
||||||
|
|
||||||
|
## 4. Marking an item resolved
|
||||||
|
|
||||||
|
Findings are **never deleted** — they are an audit trail. To close one, change
|
||||||
|
its **Status** and complete the **Resolution** field:
|
||||||
|
|
||||||
|
- `Open` — newly recorded, not yet addressed.
|
||||||
|
- `In Progress` — a fix is actively being worked on.
|
||||||
|
- `Resolved` — fixed. The Resolution field must state the fixing commit SHA, the
|
||||||
|
date, and a one-line description of the fix.
|
||||||
|
- `Won't Fix` — intentionally not fixed. The Resolution field must justify why.
|
||||||
|
- `Deferred` — valid but postponed. The Resolution field must say what it is
|
||||||
|
waiting on (e.g. a tracked issue or a later milestone).
|
||||||
|
|
||||||
|
`Resolved`, `Won't Fix`, and `Deferred` findings are all considered **closed**.
|
||||||
|
`Open` and `In Progress` are **pending** and appear in the base README's Pending
|
||||||
|
Findings table.
|
||||||
|
|
||||||
|
## 5. Updating the base README
|
||||||
|
|
||||||
|
`code-reviews/README.md` holds the single cross-module view (the Module Status
|
||||||
|
table and the Pending / Closed Findings tables). It is **generated** from the
|
||||||
|
per-module `findings.md` files — do not edit it by hand.
|
||||||
|
|
||||||
|
After any review or status change, regenerate it:
|
||||||
|
|
||||||
|
```
|
||||||
|
python code-reviews/regen-readme.py
|
||||||
|
```
|
||||||
|
|
||||||
|
`regen-readme.py --check` exits non-zero if `README.md` is stale, if a module
|
||||||
|
header's `Open findings` count disagrees with its finding statuses, or if a
|
||||||
|
finding carries an unrecognised Status value. The PowerShell wrapper
|
||||||
|
`scripts/check-code-reviews-readme.ps1` runs that check and is the intended hook
|
||||||
|
for CI or a pre-commit step.
|
||||||
|
|
||||||
|
> The repo's installed `python` is the real interpreter; the bare `python3`
|
||||||
|
> alias resolves to the Windows Store stub and fails. Use `python`.
|
||||||
|
|
||||||
|
The per-module `findings.md` files are the source of truth; `README.md` is the
|
||||||
|
aggregated index and must always agree with them — which the script guarantees.
|
||||||
|
|
||||||
|
## 6. Re-reviewing a module
|
||||||
|
|
||||||
|
Re-reviews append to the same `findings.md`. Update the header to the new commit
|
||||||
|
and date, continue the finding numbering from the last used ID, and leave prior
|
||||||
|
findings (including closed ones) in place as history.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<Project>
|
||||||
|
<!--
|
||||||
|
Mirrors src/Directory.Build.props for the .NET client projects under
|
||||||
|
clients/dotnet/ so they share the same enforcement floor (warnings-as-
|
||||||
|
errors, latest analyzers, code-style enforcement, deterministic builds)
|
||||||
|
even though they live outside src/.
|
||||||
|
-->
|
||||||
|
<PropertyGroup>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<AnalysisLevel>latest</AnalysisLevel>
|
||||||
|
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||||
|
<Deterministic>true</Deterministic>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -6,8 +6,8 @@ Provide an idiomatic .NET 10 C# client library for MXAccess Gateway, plus a test
|
|||||||
CLI and unit tests. This client is for modern .NET callers and must not load
|
CLI and unit tests. This client is for modern .NET callers and must not load
|
||||||
MXAccess COM.
|
MXAccess COM.
|
||||||
|
|
||||||
Follow the [C# Style Guide](./style-guides/CSharpStyleGuide.md) for
|
Follow the [C# Style Guide](../../docs/style-guides/CSharpStyleGuide.md) for
|
||||||
handwritten code and the [Protobuf Style Guide](./style-guides/ProtobufStyleGuide.md)
|
handwritten code and the [Protobuf Style Guide](../../docs/style-guides/ProtobufStyleGuide.md)
|
||||||
for generated contract inputs.
|
for generated contract inputs.
|
||||||
|
|
||||||
## Projects
|
## Projects
|
||||||
@@ -16,6 +16,7 @@ Recommended layout:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
clients/dotnet/
|
clients/dotnet/
|
||||||
|
MxGateway.Client.sln
|
||||||
MxGateway.Client/
|
MxGateway.Client/
|
||||||
MxGateway.Client.csproj
|
MxGateway.Client.csproj
|
||||||
GatewayClient.cs
|
GatewayClient.cs
|
||||||
@@ -41,6 +42,12 @@ Target framework:
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The scaffold uses a project reference to
|
||||||
|
`src/MxGateway.Contracts/MxGateway.Contracts.csproj` for generated protobuf and
|
||||||
|
gRPC types. `clients/dotnet/generated` remains reserved for client-local
|
||||||
|
generator output if the .NET client later needs to decouple from the contracts
|
||||||
|
project.
|
||||||
|
|
||||||
Expected packages:
|
Expected packages:
|
||||||
|
|
||||||
- `Grpc.Net.Client`
|
- `Grpc.Net.Client`
|
||||||
@@ -76,6 +83,12 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
public Task<int> AddItem2Async(int serverHandle, string item, string context, CancellationToken ct = default);
|
public Task<int> AddItem2Async(int serverHandle, string item, string context, CancellationToken ct = default);
|
||||||
public Task AdviseAsync(int serverHandle, int itemHandle, CancellationToken ct = default);
|
public Task AdviseAsync(int serverHandle, int itemHandle, CancellationToken ct = default);
|
||||||
public Task UnAdviseAsync(int serverHandle, int itemHandle, CancellationToken ct = default);
|
public Task UnAdviseAsync(int serverHandle, int itemHandle, CancellationToken ct = default);
|
||||||
|
public Task<IReadOnlyList<SubscribeResult>> AddItemBulkAsync(int serverHandle, IReadOnlyList<string> tagAddresses, CancellationToken ct = default);
|
||||||
|
public Task<IReadOnlyList<SubscribeResult>> AdviseItemBulkAsync(int serverHandle, IReadOnlyList<int> itemHandles, CancellationToken ct = default);
|
||||||
|
public Task<IReadOnlyList<SubscribeResult>> RemoveItemBulkAsync(int serverHandle, IReadOnlyList<int> itemHandles, CancellationToken ct = default);
|
||||||
|
public Task<IReadOnlyList<SubscribeResult>> UnAdviseItemBulkAsync(int serverHandle, IReadOnlyList<int> itemHandles, CancellationToken ct = default);
|
||||||
|
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(int serverHandle, IReadOnlyList<string> tagAddresses, CancellationToken ct = default);
|
||||||
|
public Task<IReadOnlyList<SubscribeResult>> UnsubscribeBulkAsync(int serverHandle, IReadOnlyList<int> itemHandles, CancellationToken ct = default);
|
||||||
public Task WriteAsync(int serverHandle, int itemHandle, MxValue value, int userId, CancellationToken ct = default);
|
public Task WriteAsync(int serverHandle, int itemHandle, MxValue value, int userId, CancellationToken ct = default);
|
||||||
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken ct = default);
|
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken ct = default);
|
||||||
public Task CloseAsync(CancellationToken ct = default);
|
public Task CloseAsync(CancellationToken ct = default);
|
||||||
@@ -97,10 +110,17 @@ public sealed class MxGatewayClientOptions
|
|||||||
public string? ServerNameOverride { get; init; }
|
public string? ServerNameOverride { get; init; }
|
||||||
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10);
|
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10);
|
||||||
public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30);
|
public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30);
|
||||||
|
public MxGatewayClientRetryOptions Retry { get; init; } = new();
|
||||||
public ILoggerFactory? LoggerFactory { get; init; }
|
public ILoggerFactory? LoggerFactory { get; init; }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The .NET client applies a bounded Polly retry policy only to idempotent calls:
|
||||||
|
`CloseSession` and diagnostic `Invoke` commands such as `Ping`,
|
||||||
|
`GetSessionState`, and `GetWorkerInfo`. It does not retry `OpenSession`, event
|
||||||
|
streams, writes, secured writes, authentication, registration, item management,
|
||||||
|
or subscription changes because those calls can partially succeed in MXAccess.
|
||||||
|
|
||||||
API key may be loaded from `MXGATEWAY_API_KEY` by the CLI, not implicitly by the
|
API key may be loaded from `MXGATEWAY_API_KEY` by the CLI, not implicitly by the
|
||||||
library constructor unless a helper explicitly says it does that.
|
library constructor unless a helper explicitly says it does that.
|
||||||
|
|
||||||
@@ -191,3 +211,10 @@ MXGATEWAY_TEST_ITEM=<item>
|
|||||||
|
|
||||||
Integration smoke should open, register, add, advise, stream for bounded time,
|
Integration smoke should open, register, add, advise, stream for bounded time,
|
||||||
and close.
|
and close.
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Client Libraries Detailed Design](../../docs/ClientLibrariesDesign.md)
|
||||||
|
- [Client Proto Generation](../../docs/ClientProtoGeneration.md)
|
||||||
|
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||||
|
- [C# Style Guide](../../docs/style-guides/CSharpStyleGuide.md)
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace MxGateway.Client.Cli;
|
||||||
|
|
||||||
|
/// <summary>Parses command-line arguments into flags and named values.</summary>
|
||||||
|
internal sealed class CliArguments
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, string> _values = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly HashSet<string> _flags = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance by parsing the given command-line arguments.</summary>
|
||||||
|
/// <param name="args">Unparsed command-line arguments; flags prefixed with '--' and values follow their flag.</param>
|
||||||
|
public CliArguments(IEnumerable<string> args)
|
||||||
|
{
|
||||||
|
string? pendingName = null;
|
||||||
|
|
||||||
|
foreach (string arg in args)
|
||||||
|
{
|
||||||
|
if (arg.StartsWith("--", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
if (pendingName is not null)
|
||||||
|
{
|
||||||
|
_flags.Add(pendingName);
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingName = arg[2..];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingName is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Unexpected argument '{arg}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_values[pendingName] = arg;
|
||||||
|
pendingName = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingName is not null)
|
||||||
|
{
|
||||||
|
_flags.Add(pendingName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns whether the named flag was present in the arguments.</summary>
|
||||||
|
/// <param name="name">The flag name (without '--' prefix).</param>
|
||||||
|
public bool HasFlag(string name)
|
||||||
|
{
|
||||||
|
return _flags.Contains(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the value for a named argument, or <c>null</c> if absent.</summary>
|
||||||
|
/// <param name="name">The argument name (without '--' prefix).</param>
|
||||||
|
public string? GetOptional(string name)
|
||||||
|
{
|
||||||
|
return _values.TryGetValue(name, out string? value)
|
||||||
|
? value
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the value for a required named argument, or throws if absent.</summary>
|
||||||
|
/// <param name="name">The argument name (without '--' prefix).</param>
|
||||||
|
public string GetRequired(string name)
|
||||||
|
{
|
||||||
|
string? value = GetOptional(name);
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Missing required option --{name}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parses and returns an int32 argument, or the default value if absent.</summary>
|
||||||
|
/// <param name="name">The argument name (without '--' prefix).</param>
|
||||||
|
/// <param name="defaultValue">The default value if the argument is absent; if <c>null</c>, the argument is required.</param>
|
||||||
|
public int GetInt32(string name, int? defaultValue = null)
|
||||||
|
{
|
||||||
|
string? value = GetOptional(name);
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
if (defaultValue.HasValue)
|
||||||
|
{
|
||||||
|
return defaultValue.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ArgumentException($"Missing required option --{name}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return int.Parse(value, CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parses and returns a uint32 argument, or the default value if absent.</summary>
|
||||||
|
/// <param name="name">The argument name (without '--' prefix).</param>
|
||||||
|
/// <param name="defaultValue">The default value if the argument is absent.</param>
|
||||||
|
public uint GetUInt32(string name, uint defaultValue)
|
||||||
|
{
|
||||||
|
string? value = GetOptional(name);
|
||||||
|
return string.IsNullOrWhiteSpace(value)
|
||||||
|
? defaultValue
|
||||||
|
: uint.Parse(value, CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parses and returns a uint64 argument, or the default value if absent.</summary>
|
||||||
|
/// <param name="name">The argument name (without '--' prefix).</param>
|
||||||
|
/// <param name="defaultValue">The default value if the argument is absent.</param>
|
||||||
|
public ulong GetUInt64(string name, ulong defaultValue)
|
||||||
|
{
|
||||||
|
string? value = GetOptional(name);
|
||||||
|
return string.IsNullOrWhiteSpace(value)
|
||||||
|
? defaultValue
|
||||||
|
: ulong.Parse(value, CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parses and returns a TimeSpan argument, or the default value if absent. Supports "ms", "s", and standard TimeSpan format.</summary>
|
||||||
|
/// <param name="name">The argument name (without '--' prefix).</param>
|
||||||
|
/// <param name="defaultValue">The default value if the argument is absent.</param>
|
||||||
|
public TimeSpan GetDuration(string name, TimeSpan defaultValue)
|
||||||
|
{
|
||||||
|
string? value = GetOptional(name);
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.EndsWith("ms", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return TimeSpan.FromMilliseconds(double.Parse(value[..^2], CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.EndsWith('s'))
|
||||||
|
{
|
||||||
|
return TimeSpan.FromSeconds(double.Parse(value[..^1], CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimeSpan.Parse(value, CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
|
namespace MxGateway.Client.Cli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal transport surface the CLI talks to. Exposes only the gateway and
|
||||||
|
/// Galaxy Repository RPCs the CLI needs so tests can substitute an in-process
|
||||||
|
/// fake without standing up a real gRPC channel. The production binding is a
|
||||||
|
/// thin adapter over <see cref="MxGatewayClient"/> and <see cref="GalaxyRepositoryClient"/>.
|
||||||
|
/// </summary>
|
||||||
|
public interface IMxGatewayCliClient : IAsyncDisposable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Opens a new gateway session.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">Session open request.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||||
|
/// <returns>The session open reply.</returns>
|
||||||
|
Task<OpenSessionReply> OpenSessionAsync(
|
||||||
|
OpenSessionRequest request,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes an open gateway session.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">Session close request.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||||
|
/// <returns>The session close reply.</returns>
|
||||||
|
Task<CloseSessionReply> CloseSessionAsync(
|
||||||
|
CloseSessionRequest request,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invokes an MXAccess command on the session.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The command request.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||||
|
/// <returns>The command reply.</returns>
|
||||||
|
Task<MxCommandReply> InvokeAsync(
|
||||||
|
MxCommandRequest request,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Streams events from the gateway session.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The stream events request.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||||
|
/// <returns>An async enumerable of events.</returns>
|
||||||
|
IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
|
StreamEventsRequest request,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Acknowledges an active MXAccess alarm condition through the gateway.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The acknowledge request — alarm reference, comment, operator user.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||||
|
/// <returns>The acknowledge reply with protocol + native MxStatus.</returns>
|
||||||
|
Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||||
|
AcknowledgeAlarmRequest request,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attaches to the gateway's central alarm feed — the current active-alarm
|
||||||
|
/// snapshot followed by live transitions.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The stream request, optionally scoped by alarm-reference prefix.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||||
|
/// <returns>An async enumerable of alarm feed messages.</returns>
|
||||||
|
IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||||
|
StreamAlarmsRequest request,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests connection to the Galaxy Repository.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The connection test request.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||||
|
/// <returns>The connection test reply.</returns>
|
||||||
|
Task<TestConnectionReply> GalaxyTestConnectionAsync(
|
||||||
|
TestConnectionRequest request,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the last deployment time from the Galaxy Repository.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The last deploy time request.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||||
|
/// <returns>The last deploy time reply.</returns>
|
||||||
|
Task<GetLastDeployTimeReply> GalaxyGetLastDeployTimeAsync(
|
||||||
|
GetLastDeployTimeRequest request,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Discovers the Galaxy Repository hierarchy.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The discover hierarchy request.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||||
|
/// <returns>The discover hierarchy reply.</returns>
|
||||||
|
Task<DiscoverHierarchyReply> GalaxyDiscoverHierarchyAsync(
|
||||||
|
DiscoverHierarchyRequest request,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Watches for deployment events from the Galaxy Repository.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The watch deploy events request.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||||
|
/// <returns>An async enumerable of deployment events.</returns>
|
||||||
|
IAsyncEnumerable<DeployEvent> GalaxyWatchDeployEventsAsync(
|
||||||
|
WatchDeployEventsRequest request,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MxGateway.Client\MxGateway.Client.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
using MxGateway.Client;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
|
namespace MxGateway.Client.Cli;
|
||||||
|
|
||||||
|
internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
|
||||||
|
{
|
||||||
|
private readonly MxGatewayClient _client;
|
||||||
|
private readonly Lazy<GalaxyRepositoryClient> _galaxyClient;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="MxGatewayCliClientAdapter"/> that bridges the CLI to the gateway client.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="client">The gateway client to adapt.</param>
|
||||||
|
public MxGatewayCliClientAdapter(MxGatewayClient client)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
_galaxyClient = new Lazy<GalaxyRepositoryClient>(
|
||||||
|
() => GalaxyRepositoryClient.Create(_client.Options));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<OpenSessionReply> OpenSessionAsync(
|
||||||
|
OpenSessionRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _client.OpenSessionRawAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<CloseSessionReply> CloseSessionAsync(
|
||||||
|
CloseSessionRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _client.CloseSessionRawAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<MxCommandReply> InvokeAsync(
|
||||||
|
MxCommandRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _client.InvokeAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
|
StreamEventsRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _client.StreamEventsAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||||
|
AcknowledgeAlarmRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _client.AcknowledgeAlarmAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||||
|
StreamAlarmsRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _client.StreamAlarmsAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<TestConnectionReply> GalaxyTestConnectionAsync(
|
||||||
|
TestConnectionRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _galaxyClient.Value.TestConnectionRawAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<GetLastDeployTimeReply> GalaxyGetLastDeployTimeAsync(
|
||||||
|
GetLastDeployTimeRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _galaxyClient.Value.GetLastDeployTimeRawAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<DiscoverHierarchyReply> GalaxyDiscoverHierarchyAsync(
|
||||||
|
DiscoverHierarchyRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _galaxyClient.Value.DiscoverHierarchyRawAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IAsyncEnumerable<DeployEvent> GalaxyWatchDeployEventsAsync(
|
||||||
|
WatchDeployEventsRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _galaxyClient.Value.WatchDeployEventsRawAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_galaxyClient.IsValueCreated)
|
||||||
|
{
|
||||||
|
await _galaxyClient.Value.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _client.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace MxGateway.Client.Cli;
|
||||||
|
|
||||||
|
/// <summary>Utility to redact API keys from error messages for safe output.</summary>
|
||||||
|
internal static class MxGatewayCliSecretRedactor
|
||||||
|
{
|
||||||
|
/// <summary>Replaces occurrences of the API key in the value with a redacted placeholder.</summary>
|
||||||
|
/// <param name="value">The message text to redact.</param>
|
||||||
|
/// <param name="apiKey">The API key to remove; no redaction if null or empty.</param>
|
||||||
|
public static string Redact(string value, string? apiKey)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(apiKey))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Replace(apiKey, "[redacted]", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
using MxGateway.Client.Cli;
|
||||||
|
|
||||||
|
return await MxGatewayClientCli.RunAsync(args, Console.Out, Console.Error);
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fake Galaxy Repository client transport for testing.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions options) : IGalaxyRepositoryClientTransport
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the gateway client options.
|
||||||
|
/// </summary>
|
||||||
|
public MxGatewayClientOptions Options { get; } = options;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the raw gRPC client; always null for the fake.
|
||||||
|
/// </summary>
|
||||||
|
public GalaxyRepository.GalaxyRepositoryClient? RawClient => null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the list of TestConnection RPC calls made by the client.
|
||||||
|
/// </summary>
|
||||||
|
public List<(TestConnectionRequest Request, CallOptions CallOptions)> TestConnectionCalls { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the list of GetLastDeployTime RPC calls made by the client.
|
||||||
|
/// </summary>
|
||||||
|
public List<(GetLastDeployTimeRequest Request, CallOptions CallOptions)> GetLastDeployTimeCalls { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the list of DiscoverHierarchy RPC calls made by the client.
|
||||||
|
/// </summary>
|
||||||
|
public List<(DiscoverHierarchyRequest Request, CallOptions CallOptions)> DiscoverHierarchyCalls { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the reply to return from TestConnection; defaults to successful response.
|
||||||
|
/// </summary>
|
||||||
|
public TestConnectionReply TestConnectionReply { get; set; } = new() { Ok = true };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the reply to return from GetLastDeployTime; defaults to no deploy time present.
|
||||||
|
/// </summary>
|
||||||
|
public GetLastDeployTimeReply GetLastDeployTimeReply { get; set; } = new() { Present = false };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the reply to return from DiscoverHierarchy; defaults to empty response.
|
||||||
|
/// </summary>
|
||||||
|
public DiscoverHierarchyReply DiscoverHierarchyReply { get; set; } = new();
|
||||||
|
|
||||||
|
public Queue<DiscoverHierarchyReply> DiscoverHierarchyReplies { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the queue of exceptions to throw from TestConnection; dequeued in FIFO order.
|
||||||
|
/// </summary>
|
||||||
|
public Queue<Exception> TestConnectionExceptions { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the queue of exceptions to throw from GetLastDeployTime; dequeued in FIFO order.
|
||||||
|
/// </summary>
|
||||||
|
public Queue<Exception> GetLastDeployTimeExceptions { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the queue of exceptions to throw from DiscoverHierarchy; dequeued in FIFO order.
|
||||||
|
/// </summary>
|
||||||
|
public Queue<Exception> DiscoverHierarchyExceptions { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records the request and either throws a queued exception or returns the configured reply.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The TestConnectionRequest to process.</param>
|
||||||
|
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||||
|
public Task<TestConnectionReply> TestConnectionAsync(
|
||||||
|
TestConnectionRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
TestConnectionCalls.Add((request, callOptions));
|
||||||
|
if (TestConnectionExceptions.TryDequeue(out Exception? exception))
|
||||||
|
{
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(TestConnectionReply);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records the request and either throws a queued exception or returns the configured reply.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The GetLastDeployTimeRequest to process.</param>
|
||||||
|
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||||
|
public Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
|
||||||
|
GetLastDeployTimeRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
GetLastDeployTimeCalls.Add((request, callOptions));
|
||||||
|
if (GetLastDeployTimeExceptions.TryDequeue(out Exception? exception))
|
||||||
|
{
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(GetLastDeployTimeReply);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records the request and either throws a queued exception or returns the configured reply.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The DiscoverHierarchyRequest to process.</param>
|
||||||
|
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||||
|
public Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
|
||||||
|
DiscoverHierarchyRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
DiscoverHierarchyCalls.Add((request, callOptions));
|
||||||
|
if (DiscoverHierarchyExceptions.TryDequeue(out Exception? exception))
|
||||||
|
{
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(
|
||||||
|
DiscoverHierarchyReplies.TryDequeue(out DiscoverHierarchyReply? reply)
|
||||||
|
? reply
|
||||||
|
: DiscoverHierarchyReply);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the list of WatchDeployEvents RPC calls made by the client.
|
||||||
|
/// </summary>
|
||||||
|
public List<(WatchDeployEventsRequest Request, CallOptions CallOptions)> WatchDeployEventsCalls { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the list of events to stream from WatchDeployEvents.
|
||||||
|
/// </summary>
|
||||||
|
public List<DeployEvent> WatchDeployEvents { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the exception to throw from WatchDeployEvents, if any.
|
||||||
|
/// </summary>
|
||||||
|
public Exception? WatchDeployEventsException { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When set, awaited before each event yield so tests can observe cancellation
|
||||||
|
/// mid-stream. Receives the call's cancellation token.
|
||||||
|
/// </summary>
|
||||||
|
public Func<CancellationToken, Task>? WatchDeployEventsBeforeYield { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records the request and streams events, checking for queued exceptions and calling WatchDeployEventsBeforeYield before each event.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The WatchDeployEventsRequest to process.</param>
|
||||||
|
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||||
|
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
||||||
|
WatchDeployEventsRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
WatchDeployEventsCalls.Add((request, callOptions));
|
||||||
|
|
||||||
|
if (WatchDeployEventsException is not null)
|
||||||
|
{
|
||||||
|
throw WatchDeployEventsException;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (DeployEvent deployEvent in WatchDeployEvents)
|
||||||
|
{
|
||||||
|
if (WatchDeployEventsBeforeYield is not null)
|
||||||
|
{
|
||||||
|
await WatchDeployEventsBeforeYield(callOptions.CancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
callOptions.CancellationToken.ThrowIfCancellationRequested();
|
||||||
|
yield return deployEvent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fake implementation of IMxGatewayClientTransport for testing.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMxGatewayClientTransport
|
||||||
|
{
|
||||||
|
private readonly Queue<MxCommandReply> _invokeReplies = new();
|
||||||
|
private readonly List<MxEvent> _events = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the gateway client options.
|
||||||
|
/// </summary>
|
||||||
|
public MxGatewayClientOptions Options { get; } = options;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets null, since this is a test fake without a real gRPC client.
|
||||||
|
/// </summary>
|
||||||
|
public MxAccessGateway.MxAccessGatewayClient? RawClient => null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the list of captured OpenSessionAsync calls.
|
||||||
|
/// </summary>
|
||||||
|
public List<(OpenSessionRequest Request, CallOptions CallOptions)> OpenSessionCalls { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the list of captured CloseSessionAsync calls.
|
||||||
|
/// </summary>
|
||||||
|
public List<(CloseSessionRequest Request, CallOptions CallOptions)> CloseSessionCalls { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the list of captured InvokeAsync calls.
|
||||||
|
/// </summary>
|
||||||
|
public List<(MxCommandRequest Request, CallOptions CallOptions)> InvokeCalls { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the list of captured StreamEventsAsync calls.
|
||||||
|
/// </summary>
|
||||||
|
public List<(StreamEventsRequest Request, CallOptions CallOptions)> StreamEventsCalls { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the list of captured AcknowledgeAlarmAsync calls.
|
||||||
|
/// </summary>
|
||||||
|
public List<(AcknowledgeAlarmRequest Request, CallOptions CallOptions)> AcknowledgeAlarmCalls { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the list of captured StreamAlarmsAsync calls.
|
||||||
|
/// </summary>
|
||||||
|
public List<(StreamAlarmsRequest Request, CallOptions CallOptions)> StreamAlarmsCalls { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the queue of exceptions to throw from AcknowledgeAlarmAsync.
|
||||||
|
/// </summary>
|
||||||
|
public Queue<Exception> AcknowledgeAlarmExceptions { get; } = new();
|
||||||
|
|
||||||
|
private readonly Queue<AcknowledgeAlarmReply> _acknowledgeReplies = new();
|
||||||
|
private readonly List<ActiveAlarmSnapshot> _activeAlarmSnapshots = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the reply to return from OpenSessionAsync.
|
||||||
|
/// </summary>
|
||||||
|
public OpenSessionReply OpenSessionReply { get; set; } = new()
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
BackendName = "mxaccess-worker",
|
||||||
|
GatewayProtocolVersion = 1,
|
||||||
|
WorkerProtocolVersion = 1,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the reply to return from CloseSessionAsync.
|
||||||
|
/// </summary>
|
||||||
|
public CloseSessionReply CloseSessionReply { get; set; } = new()
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
FinalState = SessionState.Closed,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the queue of exceptions to throw from OpenSessionAsync.
|
||||||
|
/// </summary>
|
||||||
|
public Queue<Exception> OpenSessionExceptions { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the queue of exceptions to throw from CloseSessionAsync.
|
||||||
|
/// </summary>
|
||||||
|
public Queue<Exception> CloseSessionExceptions { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether thrown <see cref="RpcException"/>s are mapped
|
||||||
|
/// to <see cref="MxGatewayException"/> the way the production gRPC transport does. Lets
|
||||||
|
/// retry tests exercise the wrapped-exception predicate branch that runs in production.
|
||||||
|
/// </summary>
|
||||||
|
public bool MapTransportExceptions { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets an optional hook awaited inside CloseSessionAsync after the call is
|
||||||
|
/// recorded; lets tests pause a close mid-flight to observe concurrent dispose.
|
||||||
|
/// </summary>
|
||||||
|
public Func<Task>? CloseSessionHook { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the queue of exceptions to throw from InvokeAsync.
|
||||||
|
/// </summary>
|
||||||
|
public Queue<Exception> InvokeExceptions { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that the OpenSessionAsync call is recorded and returns the configured reply.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The OpenSessionRequest to process.</param>
|
||||||
|
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||||
|
public Task<OpenSessionReply> OpenSessionAsync(
|
||||||
|
OpenSessionRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
OpenSessionCalls.Add((request, callOptions));
|
||||||
|
if (OpenSessionExceptions.TryDequeue(out Exception? exception))
|
||||||
|
{
|
||||||
|
throw Translate(exception, callOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(OpenSessionReply);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that the CloseSessionAsync call is recorded and returns the configured reply.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The CloseSessionRequest to process.</param>
|
||||||
|
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||||
|
public async Task<CloseSessionReply> CloseSessionAsync(
|
||||||
|
CloseSessionRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
CloseSessionCalls.Add((request, callOptions));
|
||||||
|
|
||||||
|
if (CloseSessionHook is not null)
|
||||||
|
{
|
||||||
|
await CloseSessionHook().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CloseSessionExceptions.TryDequeue(out Exception? exception))
|
||||||
|
{
|
||||||
|
throw Translate(exception, callOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CloseSessionReply;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that the InvokeAsync call is recorded and returns the next enqueued reply.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The MxCommandRequest to process.</param>
|
||||||
|
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||||
|
public Task<MxCommandReply> InvokeAsync(
|
||||||
|
MxCommandRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
InvokeCalls.Add((request, callOptions));
|
||||||
|
if (InvokeExceptions.TryDequeue(out Exception? exception))
|
||||||
|
{
|
||||||
|
throw Translate(exception, callOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(_invokeReplies.Dequeue());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that the StreamEventsAsync call is recorded and yields all enqueued events.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The StreamEventsRequest to process.</param>
|
||||||
|
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||||
|
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
|
StreamEventsRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
StreamEventsCalls.Add((request, callOptions));
|
||||||
|
|
||||||
|
foreach (MxEvent gatewayEvent in _events)
|
||||||
|
{
|
||||||
|
callOptions.CancellationToken.ThrowIfCancellationRequested();
|
||||||
|
await Task.Yield();
|
||||||
|
yield return gatewayEvent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enqueues a reply to be returned from the next InvokeAsync call.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="reply">The reply to enqueue.</param>
|
||||||
|
public void AddInvokeReply(MxCommandReply reply)
|
||||||
|
{
|
||||||
|
_invokeReplies.Enqueue(reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enqueues an event to be yielded from StreamEventsAsync.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="gatewayEvent">The event to enqueue.</param>
|
||||||
|
public void AddEvent(MxEvent gatewayEvent)
|
||||||
|
{
|
||||||
|
_events.Add(gatewayEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records the acknowledge call and returns the next enqueued reply (or default).
|
||||||
|
/// </summary>
|
||||||
|
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||||
|
AcknowledgeAlarmRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
AcknowledgeAlarmCalls.Add((request, callOptions));
|
||||||
|
if (AcknowledgeAlarmExceptions.TryDequeue(out Exception? exception))
|
||||||
|
{
|
||||||
|
throw Translate(exception, callOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(_acknowledgeReplies.Count > 0
|
||||||
|
? _acknowledgeReplies.Dequeue()
|
||||||
|
: new AcknowledgeAlarmReply
|
||||||
|
{
|
||||||
|
CorrelationId = request.ClientCorrelationId,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
Status = new MxStatusProxy { Success = 1, Category = MxStatusCategory.Ok },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records the call and yields each enqueued snapshot as an active-alarm
|
||||||
|
/// feed message, then a snapshot-complete sentinel.
|
||||||
|
/// </summary>
|
||||||
|
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||||
|
StreamAlarmsRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
StreamAlarmsCalls.Add((request, callOptions));
|
||||||
|
|
||||||
|
foreach (ActiveAlarmSnapshot snapshot in _activeAlarmSnapshots)
|
||||||
|
{
|
||||||
|
callOptions.CancellationToken.ThrowIfCancellationRequested();
|
||||||
|
await Task.Yield();
|
||||||
|
yield return new AlarmFeedMessage { ActiveAlarm = snapshot };
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return new AlarmFeedMessage { SnapshotComplete = true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Enqueues an acknowledge reply.</summary>
|
||||||
|
public void AddAcknowledgeReply(AcknowledgeAlarmReply reply)
|
||||||
|
{
|
||||||
|
_acknowledgeReplies.Enqueue(reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Enqueues a snapshot yielded from StreamAlarmsAsync as an active-alarm message.</summary>
|
||||||
|
public void AddActiveAlarmSnapshot(ActiveAlarmSnapshot snapshot)
|
||||||
|
{
|
||||||
|
_activeAlarmSnapshots.Add(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a queued exception the way the production gRPC transport does when
|
||||||
|
/// <see cref="MapTransportExceptions"/> is set; otherwise returns it unchanged.
|
||||||
|
/// </summary>
|
||||||
|
private Exception Translate(Exception exception, CallOptions callOptions)
|
||||||
|
{
|
||||||
|
if (MapTransportExceptions && exception is RpcException rpcException)
|
||||||
|
{
|
||||||
|
return RpcExceptionMapper.Map(rpcException, callOptions.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
using Grpc.Core;
|
||||||
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
public sealed class GalaxyRepositoryClientTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that TestConnectionAsync attaches the API key in request metadata and returns the Ok flag.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task TestConnectionAsync_AttachesApiKeyMetadataAndReturnsOkFlag()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource cancellation = new();
|
||||||
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
||||||
|
transport.TestConnectionReply = new TestConnectionReply { Ok = true };
|
||||||
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
bool ok = await client.TestConnectionAsync(cancellation.Token);
|
||||||
|
|
||||||
|
Assert.True(ok);
|
||||||
|
var call = Assert.Single(transport.TestConnectionCalls);
|
||||||
|
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that TestConnectionAsync returns false when the server reports NotOk.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task TestConnectionAsync_ReturnsFalseWhenServerReportsNotOk()
|
||||||
|
{
|
||||||
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
||||||
|
transport.TestConnectionReply = new TestConnectionReply { Ok = false };
|
||||||
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
bool ok = await client.TestConnectionAsync();
|
||||||
|
|
||||||
|
Assert.False(ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that GetLastDeployTimeAsync returns null when the server reports not present.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetLastDeployTimeAsync_ReturnsNullWhenNotPresent()
|
||||||
|
{
|
||||||
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
||||||
|
transport.GetLastDeployTimeReply = new GetLastDeployTimeReply { Present = false };
|
||||||
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
DateTime? deployTime = await client.GetLastDeployTimeAsync();
|
||||||
|
|
||||||
|
Assert.Null(deployTime);
|
||||||
|
Assert.Single(transport.GetLastDeployTimeCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that GetLastDeployTimeAsync returns the timestamp when the server reports it present.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetLastDeployTimeAsync_ReturnsTimestampWhenPresent()
|
||||||
|
{
|
||||||
|
DateTime expected = new(2026, 4, 28, 14, 30, 0, DateTimeKind.Utc);
|
||||||
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
||||||
|
transport.GetLastDeployTimeReply = new GetLastDeployTimeReply
|
||||||
|
{
|
||||||
|
Present = true,
|
||||||
|
TimeOfLastDeploy = Timestamp.FromDateTime(expected),
|
||||||
|
};
|
||||||
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
DateTime? deployTime = await client.GetLastDeployTimeAsync();
|
||||||
|
|
||||||
|
Assert.NotNull(deployTime);
|
||||||
|
Assert.Equal(expected, deployTime!.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that DiscoverHierarchyAsync returns the objects from the server reply.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscoverHierarchyAsync_ReturnsObjectsFromReply()
|
||||||
|
{
|
||||||
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
||||||
|
transport.DiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
|
||||||
|
{
|
||||||
|
NextPageToken = "page-2",
|
||||||
|
TotalObjectCount = 2,
|
||||||
|
Objects =
|
||||||
|
{
|
||||||
|
new GalaxyObject
|
||||||
|
{
|
||||||
|
GobjectId = 12,
|
||||||
|
TagName = "DelmiaReceiver_001",
|
||||||
|
ContainedName = "DelmiaReceiver",
|
||||||
|
BrowseName = "TestMachine_001/DelmiaReceiver",
|
||||||
|
ParentGobjectId = 5,
|
||||||
|
Attributes =
|
||||||
|
{
|
||||||
|
new GalaxyAttribute
|
||||||
|
{
|
||||||
|
AttributeName = "DownloadPath",
|
||||||
|
FullTagReference = "DelmiaReceiver_001.DownloadPath",
|
||||||
|
MxDataType = 8,
|
||||||
|
DataTypeName = "MxString",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
transport.DiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
|
||||||
|
{
|
||||||
|
TotalObjectCount = 2,
|
||||||
|
Objects =
|
||||||
|
{
|
||||||
|
new GalaxyObject
|
||||||
|
{
|
||||||
|
GobjectId = 13,
|
||||||
|
TagName = "DelmiaReceiver_002",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
IReadOnlyList<GalaxyObject> objects = await client.DiscoverHierarchyAsync();
|
||||||
|
|
||||||
|
Assert.Equal(2, objects.Count);
|
||||||
|
Assert.Equal(2, transport.DiscoverHierarchyCalls.Count);
|
||||||
|
Assert.Equal(5000, transport.DiscoverHierarchyCalls[0].Request.PageSize);
|
||||||
|
Assert.Equal("", transport.DiscoverHierarchyCalls[0].Request.PageToken);
|
||||||
|
Assert.Equal("page-2", transport.DiscoverHierarchyCalls[1].Request.PageToken);
|
||||||
|
GalaxyObject obj = objects[0];
|
||||||
|
Assert.Equal(12, obj.GobjectId);
|
||||||
|
Assert.Equal("DelmiaReceiver_001", obj.TagName);
|
||||||
|
GalaxyAttribute attribute = Assert.Single(obj.Attributes);
|
||||||
|
Assert.Equal("DownloadPath", attribute.AttributeName);
|
||||||
|
Assert.Equal("DelmiaReceiver_001.DownloadPath", attribute.FullTagReference);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that DiscoverHierarchyAsync propagates cancellation tokens to the transport.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscoverHierarchyAsync_PropagatesCancellationToTransport()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource cancellation = new();
|
||||||
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
||||||
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
await client.DiscoverHierarchyAsync(cancellation.Token);
|
||||||
|
|
||||||
|
var call = Assert.Single(transport.DiscoverHierarchyCalls);
|
||||||
|
// The retry pipeline links the caller token with a per-call timeout token,
|
||||||
|
// so the transport sees the linked token rather than the caller's directly.
|
||||||
|
// Verify the link relationship by cancelling the caller and checking the
|
||||||
|
// call-side token reflects it.
|
||||||
|
Assert.False(call.CallOptions.CancellationToken.IsCancellationRequested);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that TestConnectionAsync retries on transient gRPC failures.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscoverHierarchyAsync_WithRepeatedPageToken_ThrowsProtocolError()
|
||||||
|
{
|
||||||
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
||||||
|
transport.DiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
|
||||||
|
{
|
||||||
|
NextPageToken = "7:1",
|
||||||
|
});
|
||||||
|
transport.DiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
|
||||||
|
{
|
||||||
|
NextPageToken = "7:1",
|
||||||
|
});
|
||||||
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
MxGatewayException exception = await Assert.ThrowsAsync<MxGatewayException>(
|
||||||
|
async () => await client.DiscoverHierarchyAsync());
|
||||||
|
|
||||||
|
Assert.Contains("repeated page token", exception.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscoverHierarchyAsync_WithOptions_MapsTypedFilters()
|
||||||
|
{
|
||||||
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
||||||
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
await client.DiscoverHierarchyAsync(new DiscoverHierarchyOptions
|
||||||
|
{
|
||||||
|
RootContainedPath = "Area1/Line3",
|
||||||
|
MaxDepth = 2,
|
||||||
|
CategoryIds = [10, 13],
|
||||||
|
TemplateChainContains = ["Pump"],
|
||||||
|
TagNameGlob = "Pump_*",
|
||||||
|
IncludeAttributes = false,
|
||||||
|
AlarmBearingOnly = true,
|
||||||
|
HistorizedOnly = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
DiscoverHierarchyRequest request = Assert.Single(transport.DiscoverHierarchyCalls).Request;
|
||||||
|
Assert.Equal(DiscoverHierarchyRequest.RootOneofCase.RootContainedPath, request.RootCase);
|
||||||
|
Assert.Equal("Area1/Line3", request.RootContainedPath);
|
||||||
|
Assert.Equal(2, request.MaxDepth);
|
||||||
|
Assert.Equal([10, 13], request.CategoryIds);
|
||||||
|
Assert.Equal(["Pump"], request.TemplateChainContains);
|
||||||
|
Assert.Equal("Pump_*", request.TagNameGlob);
|
||||||
|
Assert.True(request.HasIncludeAttributes);
|
||||||
|
Assert.False(request.IncludeAttributes);
|
||||||
|
Assert.True(request.AlarmBearingOnly);
|
||||||
|
Assert.True(request.HistorizedOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TestConnectionAsync_RetriesOnTransientGrpcFailure()
|
||||||
|
{
|
||||||
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
||||||
|
transport.TestConnectionExceptions.Enqueue(CreateTransientRpcException());
|
||||||
|
transport.TestConnectionReply = new TestConnectionReply { Ok = true };
|
||||||
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
bool ok = await client.TestConnectionAsync();
|
||||||
|
|
||||||
|
Assert.True(ok);
|
||||||
|
Assert.Equal(2, transport.TestConnectionCalls.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that DiscoverHierarchyAsync retries on transient gRPC failures.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscoverHierarchyAsync_RetriesOnTransientGrpcFailure()
|
||||||
|
{
|
||||||
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
||||||
|
transport.DiscoverHierarchyExceptions.Enqueue(CreateTransientRpcException());
|
||||||
|
transport.DiscoverHierarchyReply = new DiscoverHierarchyReply();
|
||||||
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
await client.DiscoverHierarchyAsync();
|
||||||
|
|
||||||
|
Assert.Equal(2, transport.DiscoverHierarchyCalls.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that WatchDeployEventsAsync delivers the bootstrap event.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task WatchDeployEventsAsync_DeliversBootstrapEvent()
|
||||||
|
{
|
||||||
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
||||||
|
DateTime deployTime = new(2026, 4, 28, 14, 30, 0, DateTimeKind.Utc);
|
||||||
|
transport.WatchDeployEvents.Add(new DeployEvent
|
||||||
|
{
|
||||||
|
Sequence = 1,
|
||||||
|
ObservedAt = Timestamp.FromDateTime(deployTime),
|
||||||
|
TimeOfLastDeploy = Timestamp.FromDateTime(deployTime),
|
||||||
|
TimeOfLastDeployPresent = true,
|
||||||
|
ObjectCount = 7,
|
||||||
|
AttributeCount = 42,
|
||||||
|
});
|
||||||
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
List<DeployEvent> received = [];
|
||||||
|
await foreach (DeployEvent evt in client.WatchDeployEventsAsync())
|
||||||
|
{
|
||||||
|
received.Add(evt);
|
||||||
|
}
|
||||||
|
|
||||||
|
DeployEvent only = Assert.Single(received);
|
||||||
|
Assert.Equal(1ul, only.Sequence);
|
||||||
|
Assert.Equal(7, only.ObjectCount);
|
||||||
|
Assert.Equal(42, only.AttributeCount);
|
||||||
|
Assert.True(only.TimeOfLastDeployPresent);
|
||||||
|
var call = Assert.Single(transport.WatchDeployEventsCalls);
|
||||||
|
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
|
||||||
|
// No last_seen_deploy_time supplied → request leaves the field unset.
|
||||||
|
Assert.Null(call.Request.LastSeenDeployTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that WatchDeployEventsAsync delivers multiple events in order.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task WatchDeployEventsAsync_DeliversMultipleEventsInOrder()
|
||||||
|
{
|
||||||
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
||||||
|
DateTime t0 = new(2026, 4, 28, 14, 30, 0, DateTimeKind.Utc);
|
||||||
|
for (int index = 1; index <= 3; index++)
|
||||||
|
{
|
||||||
|
transport.WatchDeployEvents.Add(new DeployEvent
|
||||||
|
{
|
||||||
|
Sequence = (ulong)index,
|
||||||
|
ObservedAt = Timestamp.FromDateTime(t0.AddSeconds(index)),
|
||||||
|
TimeOfLastDeploy = Timestamp.FromDateTime(t0.AddSeconds(index)),
|
||||||
|
TimeOfLastDeployPresent = true,
|
||||||
|
ObjectCount = 10 + index,
|
||||||
|
AttributeCount = 100 + index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeOffset lastSeen = new(t0, TimeSpan.Zero);
|
||||||
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
List<DeployEvent> received = [];
|
||||||
|
await foreach (DeployEvent evt in client.WatchDeployEventsAsync(lastSeen))
|
||||||
|
{
|
||||||
|
received.Add(evt);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(3, received.Count);
|
||||||
|
Assert.Equal(new ulong[] { 1, 2, 3 }, received.Select(e => e.Sequence).ToArray());
|
||||||
|
Assert.Equal(new[] { 11, 12, 13 }, received.Select(e => e.ObjectCount).ToArray());
|
||||||
|
var call = Assert.Single(transport.WatchDeployEventsCalls);
|
||||||
|
Assert.NotNull(call.Request.LastSeenDeployTime);
|
||||||
|
Assert.Equal(t0, call.Request.LastSeenDeployTime!.ToDateTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that WatchDeployEventsAsync stops iteration cleanly when cancelled.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task WatchDeployEventsAsync_CancellationStopsIterationCleanly()
|
||||||
|
{
|
||||||
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
||||||
|
// Add many events; the test will cancel after the first.
|
||||||
|
for (int index = 1; index <= 10; index++)
|
||||||
|
{
|
||||||
|
transport.WatchDeployEvents.Add(new DeployEvent { Sequence = (ulong)index });
|
||||||
|
}
|
||||||
|
|
||||||
|
using CancellationTokenSource cancellation = new();
|
||||||
|
// Cancel before the second yield by wiring the fake's pre-yield hook.
|
||||||
|
int yields = 0;
|
||||||
|
transport.WatchDeployEventsBeforeYield = _ =>
|
||||||
|
{
|
||||||
|
yields++;
|
||||||
|
if (yields >= 2)
|
||||||
|
{
|
||||||
|
cancellation.Cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
List<DeployEvent> received = [];
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
|
||||||
|
{
|
||||||
|
await foreach (DeployEvent evt in client
|
||||||
|
.WatchDeployEventsAsync(cancellationToken: cancellation.Token))
|
||||||
|
{
|
||||||
|
received.Add(evt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The first event yields before cancellation triggers on the second pass.
|
||||||
|
Assert.Single(received);
|
||||||
|
Assert.Equal(1ul, received[0].Sequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that WatchDeployEventsAsync throws ObjectDisposedException after the client is disposed.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task WatchDeployEventsAsync_ThrowsAfterDisposal()
|
||||||
|
{
|
||||||
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
||||||
|
GalaxyRepositoryClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
await client.DisposeAsync();
|
||||||
|
|
||||||
|
Assert.Throws<ObjectDisposedException>(() =>
|
||||||
|
client.WatchDeployEventsAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that TestConnectionAsync throws ObjectDisposedException after the client is disposed.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task TestConnectionAsync_ThrowsAfterDisposal()
|
||||||
|
{
|
||||||
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
||||||
|
GalaxyRepositoryClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
await client.DisposeAsync();
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ObjectDisposedException>(() => client.TestConnectionAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GalaxyRepositoryClient CreateClient(FakeGalaxyRepositoryTransport transport)
|
||||||
|
{
|
||||||
|
return new GalaxyRepositoryClient(transport.Options, transport);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FakeGalaxyRepositoryTransport CreateTransport()
|
||||||
|
{
|
||||||
|
return new FakeGalaxyRepositoryTransport(new MxGatewayClientOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost:5000"),
|
||||||
|
ApiKey = "test-api-key",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RpcException CreateTransientRpcException()
|
||||||
|
{
|
||||||
|
return new RpcException(new Status(StatusCode.Unavailable, "gateway unavailable"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using Google.Protobuf;
|
||||||
|
using MxGateway.Client;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
public sealed class MxCommandReplyExtensionsTests
|
||||||
|
{
|
||||||
|
/// <summary>Verifies that successful replies pass both protocol and MxAccess success checks.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void EnsureSuccess_WithRegisterFixture_ReturnsReply()
|
||||||
|
{
|
||||||
|
MxCommandReply reply = ReadReplyFixture("register.ok.reply.json");
|
||||||
|
|
||||||
|
Assert.Same(reply, reply.EnsureProtocolSuccess());
|
||||||
|
Assert.Same(reply, reply.EnsureMxAccessSuccess());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that MxAccess failures throw with preserved HResult and status details.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void EnsureMxAccessSuccess_WithFailureFixture_PreservesHResultAndStatuses()
|
||||||
|
{
|
||||||
|
MxCommandReply reply = ReadReplyFixture("write.mxaccess-failure.reply.json");
|
||||||
|
|
||||||
|
reply.EnsureProtocolSuccess();
|
||||||
|
MxAccessException exception = Assert.Throws<MxAccessException>(
|
||||||
|
reply.EnsureMxAccessSuccess);
|
||||||
|
|
||||||
|
Assert.Equal(-2147220992, exception.HResultCode);
|
||||||
|
Assert.Equal(reply.Statuses.Count, exception.Statuses.Count);
|
||||||
|
Assert.Equal(reply, exception.Reply);
|
||||||
|
Assert.Contains("0x80040200", exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that session-not-found protocol failures throw the correct gateway exception.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void EnsureProtocolSuccess_WithSessionFailure_ThrowsSessionException()
|
||||||
|
{
|
||||||
|
MxCommandReply reply = new()
|
||||||
|
{
|
||||||
|
SessionId = "session-missing",
|
||||||
|
CorrelationId = "correlation",
|
||||||
|
ProtocolStatus = new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.SessionNotFound,
|
||||||
|
Message = "Session was not found.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
MxGatewaySessionException exception = Assert.Throws<MxGatewaySessionException>(
|
||||||
|
reply.EnsureProtocolSuccess);
|
||||||
|
|
||||||
|
Assert.Equal("session-missing", exception.SessionId);
|
||||||
|
Assert.Equal(ProtocolStatusCode.SessionNotFound, exception.ProtocolStatus?.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxCommandReply ReadReplyFixture(string fileName)
|
||||||
|
{
|
||||||
|
DirectoryInfo directory = new(AppContext.BaseDirectory);
|
||||||
|
while (directory is not null)
|
||||||
|
{
|
||||||
|
string path = Path.Combine(
|
||||||
|
directory.FullName,
|
||||||
|
"clients",
|
||||||
|
"proto",
|
||||||
|
"fixtures",
|
||||||
|
"behavior",
|
||||||
|
"command-replies",
|
||||||
|
fileName);
|
||||||
|
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
return JsonParser.Default.Parse<MxCommandReply>(File.ReadAllText(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
directory = directory.Parent!;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FileNotFoundException(fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MxGateway.Client\MxGateway.Client.csproj" />
|
||||||
|
<ProjectReference Include="..\MxGateway.Client.Cli\MxGateway.Client.Cli.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
using Grpc.Core;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pins the .NET SDK surface for the alarm RPCs:
|
||||||
|
/// <see cref="MxGatewayClient.AcknowledgeAlarmAsync"/> and
|
||||||
|
/// <see cref="MxGatewayClient.StreamAlarmsAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MxGatewayClientAlarmsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task AcknowledgeAlarmAsync_RecordsRequestShapeAndReturnsReply()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.AddAcknowledgeReply(new AcknowledgeAlarmReply
|
||||||
|
{
|
||||||
|
CorrelationId = "corr-1",
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
Status = new MxStatusProxy
|
||||||
|
{
|
||||||
|
Success = 1,
|
||||||
|
Category = MxStatusCategory.Ok,
|
||||||
|
DetectedBy = MxStatusSource.RespondingLmx,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
AcknowledgeAlarmReply reply = await client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
|
||||||
|
{
|
||||||
|
ClientCorrelationId = "corr-1",
|
||||||
|
AlarmFullReference = "Tank01.Level.HiHi",
|
||||||
|
Comment = "investigating",
|
||||||
|
OperatorUser = "alice",
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||||
|
Assert.Equal(MxStatusCategory.Ok, reply.Status.Category);
|
||||||
|
|
||||||
|
var call = Assert.Single(transport.AcknowledgeAlarmCalls);
|
||||||
|
Assert.Equal("Tank01.Level.HiHi", call.Request.AlarmFullReference);
|
||||||
|
Assert.Equal("investigating", call.Request.Comment);
|
||||||
|
Assert.Equal("alice", call.Request.OperatorUser);
|
||||||
|
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AcknowledgeAlarmAsync_HonorsCancellation()
|
||||||
|
{
|
||||||
|
// Acks are routed through the safe-unary retry pipeline (idempotent at the
|
||||||
|
// MxAccess level), so the transport-side cancellation token is a linked one
|
||||||
|
// rather than the caller's original. Verify cancellation by tripping the source
|
||||||
|
// and asserting the call observes it.
|
||||||
|
using CancellationTokenSource cancellation = new();
|
||||||
|
cancellation.Cancel();
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||||
|
client.AcknowledgeAlarmAsync(
|
||||||
|
new AcknowledgeAlarmRequest
|
||||||
|
{
|
||||||
|
AlarmFullReference = "Tank01.Level.HiHi",
|
||||||
|
Comment = string.Empty,
|
||||||
|
OperatorUser = "alice",
|
||||||
|
},
|
||||||
|
cancellation.Token));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AcknowledgeAlarmAsync_SurfacesRpcExceptionFromFakeTransportVerbatim_WhenMappingDisabled()
|
||||||
|
{
|
||||||
|
// Default FakeGatewayTransport.MapTransportExceptions is false, matching the
|
||||||
|
// historical pass-through shape: a thrown RpcException reaches the caller as
|
||||||
|
// RpcException rather than being mapped to a typed MxGatewayException. This
|
||||||
|
// test pins that shape so a future change can't silently flip it.
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.AcknowledgeAlarmExceptions.Enqueue(
|
||||||
|
new RpcException(new Status(StatusCode.Unauthenticated, "expired key")));
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<RpcException>(
|
||||||
|
() => client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
|
||||||
|
{
|
||||||
|
AlarmFullReference = "Tank01.Level.HiHi",
|
||||||
|
Comment = string.Empty,
|
||||||
|
OperatorUser = "alice",
|
||||||
|
}));
|
||||||
|
Assert.Equal(StatusCode.Unauthenticated, ex.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AcknowledgeAlarmAsync_MapsUnauthenticated_RpcException_ToTypedException()
|
||||||
|
{
|
||||||
|
// Production parity: GrpcMxGatewayClientTransport.AcknowledgeAlarmAsync runs
|
||||||
|
// every thrown RpcException through RpcExceptionMapper.Map, so callers see
|
||||||
|
// MxGatewayAuthenticationException (for Unauthenticated) rather than the raw
|
||||||
|
// RpcException. The fake transport reproduces that mapping when
|
||||||
|
// MapTransportExceptions is set, letting this SDK-level test cover the same
|
||||||
|
// observable behaviour without standing up a real gRPC channel.
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.MapTransportExceptions = true;
|
||||||
|
transport.AcknowledgeAlarmExceptions.Enqueue(
|
||||||
|
new RpcException(new Status(StatusCode.Unauthenticated, "expired key")));
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<MxGatewayAuthenticationException>(
|
||||||
|
() => client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
|
||||||
|
{
|
||||||
|
AlarmFullReference = "Tank01.Level.HiHi",
|
||||||
|
Comment = string.Empty,
|
||||||
|
OperatorUser = "alice",
|
||||||
|
}));
|
||||||
|
Assert.Equal(StatusCode.Unauthenticated, ex.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamAlarmsAsync_StreamsSnapshotThenSnapshotComplete()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank01.Level.HiHi", AlarmConditionState.Active));
|
||||||
|
transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank02.Level.HiHi", AlarmConditionState.ActiveAcked));
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
List<AlarmFeedMessage> messages = [];
|
||||||
|
await foreach (AlarmFeedMessage message in client.StreamAlarmsAsync(new StreamAlarmsRequest()))
|
||||||
|
{
|
||||||
|
messages.Add(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(3, messages.Count);
|
||||||
|
Assert.Equal("Tank01.Level.HiHi", messages[0].ActiveAlarm.AlarmFullReference);
|
||||||
|
Assert.Equal(AlarmConditionState.Active, messages[0].ActiveAlarm.CurrentState);
|
||||||
|
Assert.Equal(AlarmConditionState.ActiveAcked, messages[1].ActiveAlarm.CurrentState);
|
||||||
|
Assert.True(messages[2].SnapshotComplete);
|
||||||
|
Assert.Single(transport.StreamAlarmsCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamAlarmsAsync_PassesFilterPrefix()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
await foreach (AlarmFeedMessage _ in client.StreamAlarmsAsync(new StreamAlarmsRequest
|
||||||
|
{
|
||||||
|
AlarmFilterPrefix = "Tank01.",
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
// only the snapshot-complete sentinel; verifying the request passes through
|
||||||
|
}
|
||||||
|
|
||||||
|
var call = Assert.Single(transport.StreamAlarmsCalls);
|
||||||
|
Assert.Equal("Tank01.", call.Request.AlarmFilterPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamAlarmsAsync_HonorsCancellationDuringEnumeration()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank01.Level.HiHi", AlarmConditionState.Active));
|
||||||
|
transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank02.Level.HiHi", AlarmConditionState.Active));
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
using CancellationTokenSource cancellation = new();
|
||||||
|
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
|
||||||
|
{
|
||||||
|
await foreach (AlarmFeedMessage _ in client.StreamAlarmsAsync(
|
||||||
|
new StreamAlarmsRequest(),
|
||||||
|
cancellation.Token))
|
||||||
|
{
|
||||||
|
cancellation.Cancel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ActiveAlarmSnapshot MakeSnapshot(string fullReference, AlarmConditionState state)
|
||||||
|
{
|
||||||
|
return new ActiveAlarmSnapshot
|
||||||
|
{
|
||||||
|
AlarmFullReference = fullReference,
|
||||||
|
SourceObjectReference = fullReference.Split('.')[0],
|
||||||
|
AlarmTypeName = "AnalogLimitAlarm.HiHi",
|
||||||
|
Severity = 750,
|
||||||
|
CurrentState = state,
|
||||||
|
Category = "Process",
|
||||||
|
Description = "Tank high-high level",
|
||||||
|
OriginalRaiseTimestamp = Timestamp.FromDateTime(new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc)),
|
||||||
|
LastTransitionTimestamp = Timestamp.FromDateTime(new DateTime(2026, 5, 1, 12, 0, 30, DateTimeKind.Utc)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxGatewayClient CreateClient(FakeGatewayTransport transport)
|
||||||
|
{
|
||||||
|
return new MxGatewayClient(transport.Options, transport);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FakeGatewayTransport CreateTransport()
|
||||||
|
{
|
||||||
|
return new FakeGatewayTransport(new MxGatewayClientOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost:5000"),
|
||||||
|
ApiKey = "test-api-key",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,884 @@
|
|||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
using MxGateway.Client.Cli;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
/// <summary>Tests for the CLI command interface.</summary>
|
||||||
|
public sealed class MxGatewayClientCliTests
|
||||||
|
{
|
||||||
|
/// <summary>Verifies that the version command prints compiled protocol versions.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Run_Version_PrintsCompiledProtocolVersions()
|
||||||
|
{
|
||||||
|
using var output = new StringWriter();
|
||||||
|
using var error = new StringWriter();
|
||||||
|
|
||||||
|
var exitCode = MxGatewayClientCli.Run(["version"], output, error);
|
||||||
|
|
||||||
|
Assert.Equal(0, exitCode);
|
||||||
|
Assert.Contains("gateway-protocol=3", output.ToString());
|
||||||
|
Assert.Contains("worker-protocol=1", output.ToString());
|
||||||
|
Assert.Equal(string.Empty, error.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the version command with --json flag prints JSON protocol versions.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_VersionJson_PrintsJsonProtocolVersions()
|
||||||
|
{
|
||||||
|
using var output = new StringWriter();
|
||||||
|
using var error = new StringWriter();
|
||||||
|
|
||||||
|
int exitCode = await MxGatewayClientCli.RunAsync(["version", "--json"], output, error);
|
||||||
|
|
||||||
|
Assert.Equal(0, exitCode);
|
||||||
|
Assert.Contains("\"gatewayProtocolVersion\":3", output.ToString());
|
||||||
|
Assert.Equal(string.Empty, error.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the write command builds a write request and prints JSON reply.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_Write_BuildsWriteCommandAndPrintsJsonReply()
|
||||||
|
{
|
||||||
|
using var output = new StringWriter();
|
||||||
|
using var error = new StringWriter();
|
||||||
|
FakeCliClient fakeClient = new();
|
||||||
|
fakeClient.InvokeReplies.Enqueue(new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Kind = MxCommandKind.Write,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
});
|
||||||
|
|
||||||
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||||
|
[
|
||||||
|
"write",
|
||||||
|
"--endpoint",
|
||||||
|
"http://localhost:5000",
|
||||||
|
"--api-key",
|
||||||
|
"test-api-key",
|
||||||
|
"--session-id",
|
||||||
|
"session-fixture",
|
||||||
|
"--server-handle",
|
||||||
|
"12",
|
||||||
|
"--item-handle",
|
||||||
|
"34",
|
||||||
|
"--type",
|
||||||
|
"int32",
|
||||||
|
"--value",
|
||||||
|
"123",
|
||||||
|
"--json",
|
||||||
|
],
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
_ => fakeClient);
|
||||||
|
|
||||||
|
Assert.Equal(0, exitCode);
|
||||||
|
MxCommandRequest request = Assert.Single(fakeClient.InvokeRequests);
|
||||||
|
Assert.Equal(MxCommandKind.Write, request.Command.Kind);
|
||||||
|
Assert.Equal(123, request.Command.Write.Value.Int32Value);
|
||||||
|
Assert.Contains("MX_COMMAND_KIND_WRITE", output.ToString());
|
||||||
|
Assert.Equal(string.Empty, error.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that error output redacts sensitive API key values.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_ErrorOutput_RedactsApiKey()
|
||||||
|
{
|
||||||
|
using var output = new StringWriter();
|
||||||
|
using var error = new StringWriter();
|
||||||
|
|
||||||
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||||
|
[
|
||||||
|
"open-session",
|
||||||
|
"--endpoint",
|
||||||
|
"http://localhost:5000",
|
||||||
|
"--api-key",
|
||||||
|
"secret-api-key",
|
||||||
|
],
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
_ => throw new InvalidOperationException("boom secret-api-key"));
|
||||||
|
|
||||||
|
Assert.Equal(1, exitCode);
|
||||||
|
Assert.DoesNotContain("secret-api-key", error.ToString());
|
||||||
|
Assert.Contains("[redacted]", error.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that error output redacts the API key even when it was sourced from
|
||||||
|
/// the <c>--api-key-env</c> environment variable rather than passed via
|
||||||
|
/// <c>--api-key</c> — the documented default credential path.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_ErrorOutput_RedactsApiKey_WhenSourcedFromEnvironmentVariable()
|
||||||
|
{
|
||||||
|
const string environmentVariableName = "MXGATEWAY_TEST_API_KEY_REDACT";
|
||||||
|
using var output = new StringWriter();
|
||||||
|
using var error = new StringWriter();
|
||||||
|
|
||||||
|
Environment.SetEnvironmentVariable(environmentVariableName, "env-secret-api-key");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||||
|
[
|
||||||
|
"open-session",
|
||||||
|
"--endpoint",
|
||||||
|
"http://localhost:5000",
|
||||||
|
"--api-key-env",
|
||||||
|
environmentVariableName,
|
||||||
|
],
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
_ => throw new InvalidOperationException("boom env-secret-api-key"));
|
||||||
|
|
||||||
|
Assert.Equal(1, exitCode);
|
||||||
|
Assert.DoesNotContain("env-secret-api-key", error.ToString());
|
||||||
|
Assert.Contains("[redacted]", error.ToString());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable(environmentVariableName, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that stream-events with max-events limit stops output in non-JSON format.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_StreamEvents_WithMaxEventsStopsNonJsonOutput()
|
||||||
|
{
|
||||||
|
using var output = new StringWriter();
|
||||||
|
using var error = new StringWriter();
|
||||||
|
FakeCliClient fakeClient = new();
|
||||||
|
fakeClient.Events.Add(new MxEvent
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Family = MxEventFamily.OnDataChange,
|
||||||
|
WorkerSequence = 1,
|
||||||
|
});
|
||||||
|
fakeClient.Events.Add(new MxEvent
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Family = MxEventFamily.OnWriteComplete,
|
||||||
|
WorkerSequence = 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||||
|
[
|
||||||
|
"stream-events",
|
||||||
|
"--endpoint",
|
||||||
|
"http://localhost:5000",
|
||||||
|
"--api-key",
|
||||||
|
"test-api-key",
|
||||||
|
"--session-id",
|
||||||
|
"session-fixture",
|
||||||
|
"--max-events",
|
||||||
|
"1",
|
||||||
|
],
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
_ => fakeClient);
|
||||||
|
|
||||||
|
Assert.Equal(0, exitCode);
|
||||||
|
Assert.Contains("workerSequence", output.ToString());
|
||||||
|
Assert.DoesNotContain("ON_WRITE_COMPLETE", output.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client.Dotnet-017 regression: a finite-window event collector
|
||||||
|
/// (<c>stream-events --timeout</c>) must exit 0 and emit the events
|
||||||
|
/// that arrived before the timeout fired, instead of propagating the
|
||||||
|
/// timeout-driven <see cref="OperationCanceledException"/> as an
|
||||||
|
/// unhandled exception (exit code -532462766). The fix wraps the
|
||||||
|
/// <c>await foreach</c> in a token-aware catch so the cancellation
|
||||||
|
/// ends the foreach gracefully; the aggregated JSON output still runs.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_StreamEvents_WhenTimeoutFiresAfterEvents_EmitsCollectedEventsAndExitsZero()
|
||||||
|
{
|
||||||
|
using var output = new StringWriter();
|
||||||
|
using var error = new StringWriter();
|
||||||
|
FakeCliClient fakeClient = new();
|
||||||
|
fakeClient.Events.Add(new MxEvent
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Family = MxEventFamily.OnDataChange,
|
||||||
|
WorkerSequence = 1,
|
||||||
|
});
|
||||||
|
fakeClient.Events.Add(new MxEvent
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Family = MxEventFamily.OnDataChange,
|
||||||
|
WorkerSequence = 2,
|
||||||
|
});
|
||||||
|
// Park forever after yielding the configured events so the CLI's
|
||||||
|
// --timeout drives the cancellation path.
|
||||||
|
fakeClient.StreamHangAfterEvents = async token =>
|
||||||
|
{
|
||||||
|
await Task.Delay(Timeout.InfiniteTimeSpan, token).ConfigureAwait(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||||
|
[
|
||||||
|
"stream-events",
|
||||||
|
"--endpoint",
|
||||||
|
"http://localhost:5000",
|
||||||
|
"--api-key",
|
||||||
|
"test-api-key",
|
||||||
|
"--session-id",
|
||||||
|
"session-fixture",
|
||||||
|
"--json",
|
||||||
|
"--max-events",
|
||||||
|
"200",
|
||||||
|
"--timeout",
|
||||||
|
"1s",
|
||||||
|
],
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
_ => fakeClient);
|
||||||
|
|
||||||
|
Assert.Equal(0, exitCode);
|
||||||
|
string json = output.ToString();
|
||||||
|
// Aggregate JSON output must run even though the foreach exited via
|
||||||
|
// cancellation, and it must contain both events that arrived first.
|
||||||
|
Assert.Contains("\"events\"", json);
|
||||||
|
Assert.Contains("\"workerSequence\":\"1\"", json);
|
||||||
|
Assert.Contains("\"workerSequence\":\"2\"", json);
|
||||||
|
Assert.Equal(string.Empty, error.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>Verifies that stream-alarms with --max-events stops output and distinguishes payload cases.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_StreamAlarms_WithMaxEventsStopsAndDistinguishesPayloadCases()
|
||||||
|
{
|
||||||
|
using var output = new StringWriter();
|
||||||
|
using var error = new StringWriter();
|
||||||
|
FakeCliClient fakeClient = new();
|
||||||
|
fakeClient.AlarmFeedMessages.Add(new AlarmFeedMessage
|
||||||
|
{
|
||||||
|
ActiveAlarm = new ActiveAlarmSnapshot { AlarmFullReference = "Tank01.Level.HiHi" },
|
||||||
|
});
|
||||||
|
fakeClient.AlarmFeedMessages.Add(new AlarmFeedMessage { SnapshotComplete = true });
|
||||||
|
|
||||||
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||||
|
[
|
||||||
|
"stream-alarms",
|
||||||
|
"--endpoint",
|
||||||
|
"http://localhost:5000",
|
||||||
|
"--api-key",
|
||||||
|
"test-api-key",
|
||||||
|
"--filter-prefix",
|
||||||
|
"Tank01",
|
||||||
|
"--max-events",
|
||||||
|
"1",
|
||||||
|
],
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
_ => fakeClient);
|
||||||
|
|
||||||
|
Assert.Equal(0, exitCode);
|
||||||
|
StreamAlarmsRequest request = Assert.Single(fakeClient.StreamAlarmsRequests);
|
||||||
|
Assert.Equal("Tank01", request.AlarmFilterPrefix);
|
||||||
|
string text = output.ToString();
|
||||||
|
Assert.Contains("active-alarm", text);
|
||||||
|
Assert.Contains("Tank01.Level.HiHi", text);
|
||||||
|
Assert.DoesNotContain("snapshot-complete", text);
|
||||||
|
Assert.Equal(string.Empty, error.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that acknowledge-alarm builds a request and prints the JSON reply.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply()
|
||||||
|
{
|
||||||
|
using var output = new StringWriter();
|
||||||
|
using var error = new StringWriter();
|
||||||
|
FakeCliClient fakeClient = new();
|
||||||
|
fakeClient.AcknowledgeAlarmReplies.Enqueue(new AcknowledgeAlarmReply
|
||||||
|
{
|
||||||
|
CorrelationId = "ack-fixture",
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
Hresult = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||||
|
[
|
||||||
|
"acknowledge-alarm",
|
||||||
|
"--endpoint",
|
||||||
|
"http://localhost:5000",
|
||||||
|
"--api-key",
|
||||||
|
"test-api-key",
|
||||||
|
"--reference",
|
||||||
|
"Tank01.Level.HiHi",
|
||||||
|
"--comment",
|
||||||
|
"ack from cli",
|
||||||
|
"--operator",
|
||||||
|
"operator1",
|
||||||
|
"--json",
|
||||||
|
],
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
_ => fakeClient);
|
||||||
|
|
||||||
|
Assert.Equal(0, exitCode);
|
||||||
|
AcknowledgeAlarmRequest request = Assert.Single(fakeClient.AcknowledgeAlarmRequests);
|
||||||
|
Assert.Equal("Tank01.Level.HiHi", request.AlarmFullReference);
|
||||||
|
Assert.Equal("ack from cli", request.Comment);
|
||||||
|
Assert.Equal("operator1", request.OperatorUser);
|
||||||
|
Assert.Contains("ack-fixture", output.ToString());
|
||||||
|
Assert.Equal(string.Empty, error.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that smoke command closes opened session when a command fails.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_Smoke_WhenCommandFails_ClosesOpenedSession()
|
||||||
|
{
|
||||||
|
using var output = new StringWriter();
|
||||||
|
using var error = new StringWriter();
|
||||||
|
FakeCliClient fakeClient = new()
|
||||||
|
{
|
||||||
|
InvokeFailure = new InvalidOperationException("register failed"),
|
||||||
|
};
|
||||||
|
|
||||||
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||||
|
[
|
||||||
|
"smoke",
|
||||||
|
"--endpoint",
|
||||||
|
"http://localhost:5000",
|
||||||
|
"--api-key",
|
||||||
|
"test-api-key",
|
||||||
|
"--item",
|
||||||
|
"Area001.Pump001.Speed",
|
||||||
|
"--json",
|
||||||
|
],
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
_ => fakeClient);
|
||||||
|
|
||||||
|
Assert.Equal(1, exitCode);
|
||||||
|
CloseSessionRequest closeRequest = Assert.Single(fakeClient.CloseSessionRequests);
|
||||||
|
Assert.Equal("session-fixture", closeRequest.SessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that galaxy-test-connection command prints JSON reply.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_GalaxyTestConnection_PrintsJsonReply()
|
||||||
|
{
|
||||||
|
using var output = new StringWriter();
|
||||||
|
using var error = new StringWriter();
|
||||||
|
FakeCliClient fakeClient = new()
|
||||||
|
{
|
||||||
|
GalaxyTestConnectionReply = new TestConnectionReply { Ok = true },
|
||||||
|
};
|
||||||
|
|
||||||
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||||
|
[
|
||||||
|
"galaxy-test-connection",
|
||||||
|
"--endpoint",
|
||||||
|
"http://localhost:5000",
|
||||||
|
"--api-key",
|
||||||
|
"test-api-key",
|
||||||
|
"--json",
|
||||||
|
],
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
_ => fakeClient);
|
||||||
|
|
||||||
|
Assert.Equal(0, exitCode);
|
||||||
|
Assert.Single(fakeClient.GalaxyTestConnectionRequests);
|
||||||
|
Assert.Contains("\"ok\": true", output.ToString());
|
||||||
|
Assert.Equal(string.Empty, error.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that galaxy-discover command prints hierarchy summary.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_GalaxyDiscover_PrintsHierarchySummary()
|
||||||
|
{
|
||||||
|
using var output = new StringWriter();
|
||||||
|
using var error = new StringWriter();
|
||||||
|
FakeCliClient fakeClient = new();
|
||||||
|
fakeClient.GalaxyDiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
|
||||||
|
{
|
||||||
|
NextPageToken = "7:1",
|
||||||
|
TotalObjectCount = 2,
|
||||||
|
Objects =
|
||||||
|
{
|
||||||
|
new GalaxyObject
|
||||||
|
{
|
||||||
|
GobjectId = 7,
|
||||||
|
TagName = "DelmiaReceiver_001",
|
||||||
|
ContainedName = "DelmiaReceiver",
|
||||||
|
ParentGobjectId = 1,
|
||||||
|
Attributes =
|
||||||
|
{
|
||||||
|
new GalaxyAttribute
|
||||||
|
{
|
||||||
|
AttributeName = "DownloadPath",
|
||||||
|
FullTagReference = "DelmiaReceiver_001.DownloadPath",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fakeClient.GalaxyDiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
|
||||||
|
{
|
||||||
|
TotalObjectCount = 2,
|
||||||
|
Objects =
|
||||||
|
{
|
||||||
|
new GalaxyObject
|
||||||
|
{
|
||||||
|
GobjectId = 8,
|
||||||
|
TagName = "DelmiaReceiver_002",
|
||||||
|
ContainedName = "DelmiaReceiver",
|
||||||
|
ParentGobjectId = 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||||
|
[
|
||||||
|
"galaxy-discover",
|
||||||
|
"--endpoint",
|
||||||
|
"http://localhost:5000",
|
||||||
|
"--api-key",
|
||||||
|
"test-api-key",
|
||||||
|
],
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
_ => fakeClient);
|
||||||
|
|
||||||
|
Assert.Equal(0, exitCode);
|
||||||
|
Assert.Equal(2, fakeClient.GalaxyDiscoverHierarchyRequests.Count);
|
||||||
|
Assert.Equal(5000, fakeClient.GalaxyDiscoverHierarchyRequests[0].PageSize);
|
||||||
|
Assert.Equal("", fakeClient.GalaxyDiscoverHierarchyRequests[0].PageToken);
|
||||||
|
Assert.Equal("7:1", fakeClient.GalaxyDiscoverHierarchyRequests[1].PageToken);
|
||||||
|
string text = output.ToString();
|
||||||
|
Assert.Contains("objects=2", text);
|
||||||
|
Assert.Contains("DelmiaReceiver_001", text);
|
||||||
|
Assert.Contains("DelmiaReceiver_002", text);
|
||||||
|
Assert.Contains("attributes=1", text);
|
||||||
|
Assert.Equal(string.Empty, error.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that galaxy-watch command prints text output for deploy events.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_GalaxyWatch_PrintsTextOutputForEvents()
|
||||||
|
{
|
||||||
|
using var output = new StringWriter();
|
||||||
|
using var error = new StringWriter();
|
||||||
|
FakeCliClient fakeClient = new();
|
||||||
|
DateTime deploy = new(2026, 4, 28, 14, 30, 0, DateTimeKind.Utc);
|
||||||
|
fakeClient.GalaxyDeployEvents.Add(new DeployEvent
|
||||||
|
{
|
||||||
|
Sequence = 1,
|
||||||
|
ObservedAt = Timestamp.FromDateTime(deploy),
|
||||||
|
TimeOfLastDeploy = Timestamp.FromDateTime(deploy),
|
||||||
|
TimeOfLastDeployPresent = true,
|
||||||
|
ObjectCount = 5,
|
||||||
|
AttributeCount = 17,
|
||||||
|
});
|
||||||
|
fakeClient.GalaxyDeployEvents.Add(new DeployEvent
|
||||||
|
{
|
||||||
|
Sequence = 2,
|
||||||
|
ObservedAt = Timestamp.FromDateTime(deploy.AddSeconds(30)),
|
||||||
|
TimeOfLastDeploy = Timestamp.FromDateTime(deploy.AddSeconds(30)),
|
||||||
|
TimeOfLastDeployPresent = true,
|
||||||
|
ObjectCount = 6,
|
||||||
|
AttributeCount = 18,
|
||||||
|
});
|
||||||
|
|
||||||
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||||
|
[
|
||||||
|
"galaxy-watch",
|
||||||
|
"--endpoint",
|
||||||
|
"http://localhost:5000",
|
||||||
|
"--api-key",
|
||||||
|
"test-api-key",
|
||||||
|
"--last-seen-deploy-time",
|
||||||
|
"2026-04-28T14:00:00Z",
|
||||||
|
"--max-events",
|
||||||
|
"2",
|
||||||
|
],
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
_ => fakeClient);
|
||||||
|
|
||||||
|
Assert.Equal(0, exitCode);
|
||||||
|
WatchDeployEventsRequest request = Assert.Single(fakeClient.GalaxyWatchDeployEventsRequests);
|
||||||
|
Assert.NotNull(request.LastSeenDeployTime);
|
||||||
|
string text = output.ToString();
|
||||||
|
Assert.Contains("sequence=1", text);
|
||||||
|
Assert.Contains("sequence=2", text);
|
||||||
|
Assert.Contains("objects=5", text);
|
||||||
|
Assert.Contains("attributes=18", text);
|
||||||
|
Assert.Equal(string.Empty, error.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that galaxy-watch with --json emits one JSON object per event.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_GalaxyWatch_JsonEmitsOneObjectPerEvent()
|
||||||
|
{
|
||||||
|
using var output = new StringWriter();
|
||||||
|
using var error = new StringWriter();
|
||||||
|
FakeCliClient fakeClient = new();
|
||||||
|
fakeClient.GalaxyDeployEvents.Add(new DeployEvent
|
||||||
|
{
|
||||||
|
Sequence = 42,
|
||||||
|
ObjectCount = 99,
|
||||||
|
AttributeCount = 1024,
|
||||||
|
});
|
||||||
|
|
||||||
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||||
|
[
|
||||||
|
"galaxy-watch",
|
||||||
|
"--endpoint",
|
||||||
|
"http://localhost:5000",
|
||||||
|
"--api-key",
|
||||||
|
"test-api-key",
|
||||||
|
"--max-events",
|
||||||
|
"1",
|
||||||
|
"--json",
|
||||||
|
],
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
_ => fakeClient);
|
||||||
|
|
||||||
|
Assert.Equal(0, exitCode);
|
||||||
|
string text = output.ToString();
|
||||||
|
Assert.Contains("\"sequence\": \"42\"", text);
|
||||||
|
Assert.Contains("\"objectCount\": 99", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that batch mode executes a single no-gateway command and writes the EOR sentinel.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_Batch_SingleVersionCommand_WritesOutputAndEorSentinel()
|
||||||
|
{
|
||||||
|
using var output = new StringWriter();
|
||||||
|
using var error = new StringWriter();
|
||||||
|
using var stdin = new StringReader("version --json\n");
|
||||||
|
|
||||||
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||||
|
["batch"],
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
clientFactory: null,
|
||||||
|
standardInput: stdin);
|
||||||
|
|
||||||
|
Assert.Equal(0, exitCode);
|
||||||
|
string text = output.ToString();
|
||||||
|
Assert.Contains("\"gatewayProtocolVersion\"", text);
|
||||||
|
Assert.Contains("__MXGW_BATCH_EOR__", text);
|
||||||
|
// Sentinel must appear after the output, not before.
|
||||||
|
int outputIdx = text.IndexOf("gatewayProtocolVersion", StringComparison.Ordinal);
|
||||||
|
int eorIdx = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
|
||||||
|
Assert.True(outputIdx < eorIdx, "EOR sentinel must follow command output.");
|
||||||
|
Assert.Equal(string.Empty, error.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that batch mode processes two commands sequentially and writes two EOR sentinels.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_Batch_TwoVersionCommands_WritesTwoEorSentinels()
|
||||||
|
{
|
||||||
|
using var output = new StringWriter();
|
||||||
|
using var error = new StringWriter();
|
||||||
|
// Two commands followed by EOF (end of string).
|
||||||
|
using var stdin = new StringReader("version\nversion --json\n");
|
||||||
|
|
||||||
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||||
|
["batch"],
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
clientFactory: null,
|
||||||
|
standardInput: stdin);
|
||||||
|
|
||||||
|
Assert.Equal(0, exitCode);
|
||||||
|
string text = output.ToString();
|
||||||
|
int firstEor = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
|
||||||
|
int secondEor = text.IndexOf("__MXGW_BATCH_EOR__", firstEor + 1, StringComparison.Ordinal);
|
||||||
|
Assert.True(firstEor >= 0, "First EOR sentinel must be present.");
|
||||||
|
Assert.True(secondEor > firstEor, "Second EOR sentinel must follow first.");
|
||||||
|
Assert.Equal(string.Empty, error.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that batch mode on EOF (empty stdin) exits 0 immediately without writing any sentinel.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_Batch_EmptyStdin_ExitsZeroWithNoOutput()
|
||||||
|
{
|
||||||
|
using var output = new StringWriter();
|
||||||
|
using var error = new StringWriter();
|
||||||
|
using var stdin = new StringReader(string.Empty);
|
||||||
|
|
||||||
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||||
|
["batch"],
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
clientFactory: null,
|
||||||
|
standardInput: stdin);
|
||||||
|
|
||||||
|
Assert.Equal(0, exitCode);
|
||||||
|
Assert.Equal(string.Empty, output.ToString());
|
||||||
|
Assert.Equal(string.Empty, error.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that batch mode continues after a command failure and writes the error JSON
|
||||||
|
/// to stdout (not stderr), followed by the EOR sentinel.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_Batch_CommandFailure_WritesErrorJsonToStdoutAndContinues()
|
||||||
|
{
|
||||||
|
using var output = new StringWriter();
|
||||||
|
using var error = new StringWriter();
|
||||||
|
// First line: a gateway command with no API key (will fail).
|
||||||
|
// Second line: version (will succeed).
|
||||||
|
using var stdin = new StringReader("open-session --endpoint http://localhost:5000\nversion --json\n");
|
||||||
|
|
||||||
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||||
|
["batch"],
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
clientFactory: _ => throw new InvalidOperationException("injected failure"),
|
||||||
|
standardInput: stdin);
|
||||||
|
|
||||||
|
Assert.Equal(0, exitCode);
|
||||||
|
string text = output.ToString();
|
||||||
|
|
||||||
|
// Error record: the error JSON must be on stdout, not stderr.
|
||||||
|
Assert.Contains("\"error\"", text);
|
||||||
|
Assert.Equal(string.Empty, error.ToString());
|
||||||
|
|
||||||
|
// Both records must be present.
|
||||||
|
int firstEor = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
|
||||||
|
int secondEor = text.IndexOf("__MXGW_BATCH_EOR__", firstEor + 1, StringComparison.Ordinal);
|
||||||
|
Assert.True(firstEor >= 0, "EOR after failed command must be present.");
|
||||||
|
Assert.True(secondEor > firstEor, "EOR after successful command must follow first EOR.");
|
||||||
|
|
||||||
|
// Second record must contain the version output.
|
||||||
|
string afterFirstEor = text[(firstEor + "__MXGW_BATCH_EOR__".Length)..];
|
||||||
|
Assert.Contains("\"gatewayProtocolVersion\"", afterFirstEor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that batch mode treats an empty (blank) line as EOF and exits 0.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_Batch_EmptyLine_ExitsZeroAfterPreviousCommands()
|
||||||
|
{
|
||||||
|
using var output = new StringWriter();
|
||||||
|
using var error = new StringWriter();
|
||||||
|
// One command, then an empty line (stop signal), then another command that must NOT run.
|
||||||
|
using var stdin = new StringReader("version --json\n\nversion --json\n");
|
||||||
|
|
||||||
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||||
|
["batch"],
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
clientFactory: null,
|
||||||
|
standardInput: stdin);
|
||||||
|
|
||||||
|
Assert.Equal(0, exitCode);
|
||||||
|
string text = output.ToString();
|
||||||
|
// Only one EOR sentinel — the second command after the empty line must not execute.
|
||||||
|
int firstEor = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
|
||||||
|
int secondEor = text.IndexOf("__MXGW_BATCH_EOR__", firstEor + 1, StringComparison.Ordinal);
|
||||||
|
Assert.True(firstEor >= 0, "One EOR sentinel must be present.");
|
||||||
|
Assert.Equal(-1, secondEor);
|
||||||
|
Assert.Equal(string.Empty, error.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fake CLI client for testing.</summary>
|
||||||
|
private sealed class FakeCliClient : IMxGatewayCliClient
|
||||||
|
{
|
||||||
|
/// <summary>Queue of invoke replies to return.</summary>
|
||||||
|
public Queue<MxCommandReply> InvokeReplies { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>List of received invoke requests.</summary>
|
||||||
|
public List<MxCommandRequest> InvokeRequests { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>List of received close session requests.</summary>
|
||||||
|
public List<CloseSessionRequest> CloseSessionRequests { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>List of events to yield when streaming.</summary>
|
||||||
|
public List<MxEvent> Events { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Exception to throw on invoke, if any.</summary>
|
||||||
|
public Exception? InvokeFailure { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When set, after yielding all <see cref="Events"/> the stream
|
||||||
|
/// awaits the provided handle and then throws
|
||||||
|
/// <see cref="OperationCanceledException"/> — used to simulate the
|
||||||
|
/// CLI timeout / Ctrl+C cancellation path (Client.Dotnet-017).
|
||||||
|
/// </summary>
|
||||||
|
public Func<CancellationToken, Task>? StreamHangAfterEvents { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<OpenSessionReply> OpenSessionAsync(
|
||||||
|
OpenSessionRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new OpenSessionReply
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
GatewayProtocolVersion = 1,
|
||||||
|
WorkerProtocolVersion = 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<CloseSessionReply> CloseSessionAsync(
|
||||||
|
CloseSessionRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
CloseSessionRequests.Add(request);
|
||||||
|
return Task.FromResult(new CloseSessionReply
|
||||||
|
{
|
||||||
|
SessionId = request.SessionId,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
FinalState = SessionState.Closed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<MxCommandReply> InvokeAsync(
|
||||||
|
MxCommandRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
InvokeRequests.Add(request);
|
||||||
|
if (InvokeFailure is not null)
|
||||||
|
{
|
||||||
|
throw InvokeFailure;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(InvokeReplies.Dequeue());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
|
StreamEventsRequest request,
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
foreach (MxEvent gatewayEvent in Events)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
await Task.Yield();
|
||||||
|
yield return gatewayEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StreamHangAfterEvents is not null)
|
||||||
|
{
|
||||||
|
await StreamHangAfterEvents(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Queue of acknowledge-alarm replies to return.</summary>
|
||||||
|
public Queue<AcknowledgeAlarmReply> AcknowledgeAlarmReplies { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>List of received acknowledge-alarm requests.</summary>
|
||||||
|
public List<AcknowledgeAlarmRequest> AcknowledgeAlarmRequests { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>List of received stream-alarms requests.</summary>
|
||||||
|
public List<StreamAlarmsRequest> StreamAlarmsRequests { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>List of alarm feed messages to yield when streaming alarms.</summary>
|
||||||
|
public List<AlarmFeedMessage> AlarmFeedMessages { get; } = [];
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||||
|
AcknowledgeAlarmRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
AcknowledgeAlarmRequests.Add(request);
|
||||||
|
return Task.FromResult(AcknowledgeAlarmReplies.Dequeue());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||||
|
StreamAlarmsRequest request,
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
StreamAlarmsRequests.Add(request);
|
||||||
|
foreach (AlarmFeedMessage feedMessage in AlarmFeedMessages)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
await Task.Yield();
|
||||||
|
yield return feedMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Galaxy test connection reply to return.</summary>
|
||||||
|
public TestConnectionReply GalaxyTestConnectionReply { get; set; } = new() { Ok = true };
|
||||||
|
|
||||||
|
/// <summary>Galaxy get last deploy time reply to return.</summary>
|
||||||
|
public GetLastDeployTimeReply GalaxyGetLastDeployTimeReply { get; set; } = new() { Present = false };
|
||||||
|
|
||||||
|
/// <summary>Galaxy discover hierarchy reply to return.</summary>
|
||||||
|
public DiscoverHierarchyReply GalaxyDiscoverHierarchyReply { get; set; } = new();
|
||||||
|
|
||||||
|
public Queue<DiscoverHierarchyReply> GalaxyDiscoverHierarchyReplies { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>List of received galaxy test connection requests.</summary>
|
||||||
|
public List<TestConnectionRequest> GalaxyTestConnectionRequests { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>List of received galaxy get last deploy time requests.</summary>
|
||||||
|
public List<GetLastDeployTimeRequest> GalaxyGetLastDeployTimeRequests { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>List of received galaxy discover hierarchy requests.</summary>
|
||||||
|
public List<DiscoverHierarchyRequest> GalaxyDiscoverHierarchyRequests { get; } = [];
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<TestConnectionReply> GalaxyTestConnectionAsync(
|
||||||
|
TestConnectionRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
GalaxyTestConnectionRequests.Add(request);
|
||||||
|
return Task.FromResult(GalaxyTestConnectionReply);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<GetLastDeployTimeReply> GalaxyGetLastDeployTimeAsync(
|
||||||
|
GetLastDeployTimeRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
GalaxyGetLastDeployTimeRequests.Add(request);
|
||||||
|
return Task.FromResult(GalaxyGetLastDeployTimeReply);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<DiscoverHierarchyReply> GalaxyDiscoverHierarchyAsync(
|
||||||
|
DiscoverHierarchyRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
GalaxyDiscoverHierarchyRequests.Add(request);
|
||||||
|
return Task.FromResult(
|
||||||
|
GalaxyDiscoverHierarchyReplies.TryDequeue(out DiscoverHierarchyReply? reply)
|
||||||
|
? reply
|
||||||
|
: GalaxyDiscoverHierarchyReply);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>List of received galaxy watch deploy events requests.</summary>
|
||||||
|
public List<WatchDeployEventsRequest> GalaxyWatchDeployEventsRequests { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>List of deploy events to yield when watching.</summary>
|
||||||
|
public List<DeployEvent> GalaxyDeployEvents { get; } = [];
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async IAsyncEnumerable<DeployEvent> GalaxyWatchDeployEventsAsync(
|
||||||
|
WatchDeployEventsRequest request,
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
GalaxyWatchDeployEventsRequests.Add(request);
|
||||||
|
foreach (DeployEvent deployEvent in GalaxyDeployEvents)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
await Task.Yield();
|
||||||
|
yield return deployEvent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using MxGateway.Contracts;
|
||||||
|
|
||||||
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
public sealed class MxGatewayClientContractInfoTests
|
||||||
|
{
|
||||||
|
/// <summary>Verifies that the client's gateway protocol version matches the shared contract definition.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void GatewayProtocolVersion_MatchesSharedContract()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
GatewayContractInfo.GatewayProtocolVersion,
|
||||||
|
MxGatewayClientContractInfo.GatewayProtocolVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the client's worker protocol version matches the shared contract definition.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void WorkerProtocolVersion_MatchesSharedContract()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
GatewayContractInfo.WorkerProtocolVersion,
|
||||||
|
MxGatewayClientContractInfo.WorkerProtocolVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
public sealed class MxGatewayClientOptionsTests
|
||||||
|
{
|
||||||
|
/// <summary>Verifies that options with valid endpoint and API key pass validation.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Validate_WithAbsoluteEndpointAndApiKey_Succeeds()
|
||||||
|
{
|
||||||
|
var options = new MxGatewayClientOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost:5000"),
|
||||||
|
ApiKey = "test-api-key",
|
||||||
|
};
|
||||||
|
|
||||||
|
options.Validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that empty API key causes validation to fail.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Validate_WithEmptyApiKey_Throws()
|
||||||
|
{
|
||||||
|
var options = new MxGatewayClientOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost:5000"),
|
||||||
|
ApiKey = "",
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentException>(options.Validate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that invalid retry options cause validation to fail.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Validate_WithInvalidRetryOptions_Throws()
|
||||||
|
{
|
||||||
|
var options = new MxGatewayClientOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost:5000"),
|
||||||
|
ApiKey = "test-api-key",
|
||||||
|
Retry = new MxGatewayClientRetryOptions { MaxAttempts = 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(options.Validate);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,567 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using Grpc.Core;
|
||||||
|
|
||||||
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
/// <summary>Tests for MxGatewaySession and client command behavior.</summary>
|
||||||
|
public sealed class MxGatewayClientSessionTests
|
||||||
|
{
|
||||||
|
/// <summary>Verifies that open session attaches API key metadata and cancellation token.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenSessionRawAsync_AttachesApiKeyMetadataAndCancellation()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource cancellation = new();
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
await client.OpenSessionRawAsync(new OpenSessionRequest(), cancellation.Token);
|
||||||
|
|
||||||
|
var call = Assert.Single(transport.OpenSessionCalls);
|
||||||
|
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
|
||||||
|
Assert.Equal(cancellation.Token, call.CallOptions.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that open session returns a session with the raw open reply.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenSessionAsync_ReturnsSessionWithRawOpenReply()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.OpenSessionReply.WorkerProcessId = 1234;
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
|
||||||
|
Assert.Equal("session-fixture", session.SessionId);
|
||||||
|
Assert.Same(transport.OpenSessionReply, session.OpenSessionReply);
|
||||||
|
Assert.Equal(1234, session.OpenSessionReply.WorkerProcessId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that register builds a register command and returns server handle.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RegisterAsync_BuildsRegisterCommandAndReturnsServerHandle()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.AddInvokeReply(new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Kind = MxCommandKind.Register,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
Register = new RegisterReply { ServerHandle = 12 },
|
||||||
|
});
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
|
||||||
|
int serverHandle = await session.RegisterAsync("fixture-client");
|
||||||
|
|
||||||
|
Assert.Equal(12, serverHandle);
|
||||||
|
var call = Assert.Single(transport.InvokeCalls);
|
||||||
|
Assert.Equal("session-fixture", call.Request.SessionId);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(call.Request.ClientCorrelationId));
|
||||||
|
Assert.Equal(MxCommandKind.Register, call.Request.Command.Kind);
|
||||||
|
Assert.Equal("fixture-client", call.Request.Command.Register.ClientName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that add item 2 builds a command with the specified context.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task AddItem2Async_BuildsAddItem2CommandWithContext()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.AddInvokeReply(new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Kind = MxCommandKind.AddItem2,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
AddItem2 = new AddItem2Reply { ItemHandle = 34 },
|
||||||
|
});
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
|
||||||
|
int itemHandle = await session.AddItem2Async(12, "Area001.Pump001.Speed", "runtime");
|
||||||
|
|
||||||
|
Assert.Equal(34, itemHandle);
|
||||||
|
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
|
||||||
|
Assert.Equal(MxCommandKind.AddItem2, request.Command.Kind);
|
||||||
|
Assert.Equal(12, request.Command.AddItem2.ServerHandle);
|
||||||
|
Assert.Equal("Area001.Pump001.Speed", request.Command.AddItem2.ItemDefinition);
|
||||||
|
Assert.Equal("runtime", request.Command.AddItem2.ItemContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that write raw builds a write command with the raw value.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteRawAsync_BuildsWriteCommandWithRawValue()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.AddInvokeReply(new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Kind = MxCommandKind.Write,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
});
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
MxValue value = new()
|
||||||
|
{
|
||||||
|
DataType = MxDataType.Integer,
|
||||||
|
VariantType = "VT_I4",
|
||||||
|
Int32Value = 123,
|
||||||
|
};
|
||||||
|
|
||||||
|
MxCommandReply reply = await session.WriteRawAsync(12, 34, value, 56);
|
||||||
|
|
||||||
|
Assert.Equal(MxCommandKind.Write, reply.Kind);
|
||||||
|
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
|
||||||
|
Assert.Equal(MxCommandKind.Write, request.Command.Kind);
|
||||||
|
Assert.Equal(12, request.Command.Write.ServerHandle);
|
||||||
|
Assert.Equal(34, request.Command.Write.ItemHandle);
|
||||||
|
Assert.Same(value, request.Command.Write.Value);
|
||||||
|
Assert.Equal(56, request.Command.Write.UserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that write 2 raw builds a write 2 command with value and timestamp.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Write2RawAsync_BuildsWrite2CommandWithValueAndTimestamp()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.AddInvokeReply(new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Kind = MxCommandKind.Write2,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
});
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
MxValue value = 123.ToMxValue();
|
||||||
|
MxValue timestampValue = DateTimeOffset.Parse("2026-01-01T00:00:00Z").ToMxValue();
|
||||||
|
|
||||||
|
MxCommandReply reply = await session.Write2RawAsync(12, 34, value, timestampValue, 56);
|
||||||
|
|
||||||
|
Assert.Equal(MxCommandKind.Write2, reply.Kind);
|
||||||
|
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
|
||||||
|
Assert.Equal(MxCommandKind.Write2, request.Command.Kind);
|
||||||
|
Assert.Equal(12, request.Command.Write2.ServerHandle);
|
||||||
|
Assert.Equal(34, request.Command.Write2.ItemHandle);
|
||||||
|
Assert.Same(value, request.Command.Write2.Value);
|
||||||
|
Assert.Same(timestampValue, request.Command.Write2.TimestampValue);
|
||||||
|
Assert.Equal(56, request.Command.Write2.UserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that subscribe bulk builds one command and returns per-item results.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task SubscribeBulkAsync_BuildsOneBulkCommandAndReturnsPerItemResults()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.AddInvokeReply(new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Kind = MxCommandKind.SubscribeBulk,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
SubscribeBulk = new BulkSubscribeReply
|
||||||
|
{
|
||||||
|
Results =
|
||||||
|
{
|
||||||
|
new SubscribeResult
|
||||||
|
{
|
||||||
|
ServerHandle = 12,
|
||||||
|
TagAddress = "Area001.Pump001.Speed",
|
||||||
|
ItemHandle = 34,
|
||||||
|
WasSuccessful = true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
|
||||||
|
IReadOnlyList<SubscribeResult> results = await session.SubscribeBulkAsync(
|
||||||
|
12,
|
||||||
|
["Area001.Pump001.Speed"]);
|
||||||
|
|
||||||
|
SubscribeResult result = Assert.Single(results);
|
||||||
|
Assert.Equal(34, result.ItemHandle);
|
||||||
|
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
|
||||||
|
Assert.Equal(MxCommandKind.SubscribeBulk, request.Command.Kind);
|
||||||
|
Assert.Equal(12, request.Command.SubscribeBulk.ServerHandle);
|
||||||
|
Assert.Equal(["Area001.Pump001.Speed"], request.Command.SubscribeBulk.TagAddresses);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that WriteBulk builds one command carrying the entry list verbatim
|
||||||
|
/// and returns the per-entry BulkWriteResult list without throwing on per-entry
|
||||||
|
/// failures.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteBulkAsync_BuildsOneBulkCommandAndReturnsPerEntryResults()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.AddInvokeReply(new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Kind = MxCommandKind.WriteBulk,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
WriteBulk = new BulkWriteReply
|
||||||
|
{
|
||||||
|
Results =
|
||||||
|
{
|
||||||
|
new BulkWriteResult { ServerHandle = 12, ItemHandle = 901, WasSuccessful = true },
|
||||||
|
new BulkWriteResult { ServerHandle = 12, ItemHandle = 902, WasSuccessful = false, ErrorMessage = "Invalid handle" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
|
||||||
|
IReadOnlyList<BulkWriteResult> results = await session.WriteBulkAsync(
|
||||||
|
12,
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new WriteBulkEntry { ItemHandle = 901, UserId = 5, Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 11 } },
|
||||||
|
new WriteBulkEntry { ItemHandle = 902, UserId = 5, Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 22 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(2, results.Count);
|
||||||
|
Assert.True(results[0].WasSuccessful);
|
||||||
|
Assert.False(results[1].WasSuccessful);
|
||||||
|
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
|
||||||
|
Assert.Equal(MxCommandKind.WriteBulk, request.Command.Kind);
|
||||||
|
Assert.Equal(12, request.Command.WriteBulk.ServerHandle);
|
||||||
|
Assert.Equal(2, request.Command.WriteBulk.Entries.Count);
|
||||||
|
Assert.Equal(901, request.Command.WriteBulk.Entries[0].ItemHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that ReadBulk forwards the timeout to the gateway as milliseconds
|
||||||
|
/// and unpacks the BulkReadReply payload's was_cached / value fields.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadBulkAsync_ForwardsTimeoutAndUnpacksCachedFlag()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.AddInvokeReply(new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Kind = MxCommandKind.ReadBulk,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
ReadBulk = new BulkReadReply
|
||||||
|
{
|
||||||
|
Results =
|
||||||
|
{
|
||||||
|
new BulkReadResult
|
||||||
|
{
|
||||||
|
ServerHandle = 12,
|
||||||
|
TagAddress = "Area001.Pump001.Speed",
|
||||||
|
ItemHandle = 901,
|
||||||
|
WasSuccessful = true,
|
||||||
|
WasCached = true,
|
||||||
|
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 99 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
|
||||||
|
IReadOnlyList<BulkReadResult> results = await session.ReadBulkAsync(
|
||||||
|
12,
|
||||||
|
["Area001.Pump001.Speed"],
|
||||||
|
TimeSpan.FromMilliseconds(750));
|
||||||
|
|
||||||
|
BulkReadResult result = Assert.Single(results);
|
||||||
|
Assert.True(result.WasCached);
|
||||||
|
Assert.Equal(99, result.Value.Int32Value);
|
||||||
|
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
|
||||||
|
Assert.Equal(MxCommandKind.ReadBulk, request.Command.Kind);
|
||||||
|
Assert.Equal(750u, request.Command.ReadBulk.TimeoutMs);
|
||||||
|
Assert.Equal(["Area001.Pump001.Speed"], request.Command.ReadBulk.TagAddresses);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that stream events yields events in the order received from the gateway.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamEventsAsync_YieldsEventsInGatewayOrder()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.AddEvent(new MxEvent
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Family = MxEventFamily.OnDataChange,
|
||||||
|
WorkerSequence = 1,
|
||||||
|
});
|
||||||
|
transport.AddEvent(new MxEvent
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Family = MxEventFamily.OnWriteComplete,
|
||||||
|
WorkerSequence = 2,
|
||||||
|
});
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
List<ulong> sequences = [];
|
||||||
|
|
||||||
|
await foreach (MxEvent gatewayEvent in session.StreamEventsAsync(afterWorkerSequence: 0))
|
||||||
|
{
|
||||||
|
sequences.Add(gatewayEvent.WorkerSequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal([1UL, 2UL], sequences);
|
||||||
|
StreamEventsRequest request = Assert.Single(transport.StreamEventsCalls).Request;
|
||||||
|
Assert.Equal("session-fixture", request.SessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that close is explicit and idempotent.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task CloseAsync_IsExplicitAndIdempotent()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
|
||||||
|
CloseSessionReply first = await session.CloseAsync();
|
||||||
|
CloseSessionReply second = await session.CloseAsync();
|
||||||
|
|
||||||
|
Assert.Same(first, second);
|
||||||
|
var call = Assert.Single(transport.CloseSessionCalls);
|
||||||
|
Assert.Equal("session-fixture", call.Request.SessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that disposing a session while other callers are concurrently inside
|
||||||
|
/// <see cref="MxGatewaySession.CloseAsync"/> — one holding the close lock and one
|
||||||
|
/// parked on it — never throws <see cref="ObjectDisposedException"/> into those
|
||||||
|
/// callers. The close lock must outlive every pending close.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task DisposeAsync_DoesNotRaceConcurrentCloseAsync()
|
||||||
|
{
|
||||||
|
for (int iteration = 0; iteration < 100; iteration++)
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
using SemaphoreSlim firstCloseEntered = new(0, 1);
|
||||||
|
using SemaphoreSlim releaseFirstClose = new(0, 1);
|
||||||
|
|
||||||
|
// The first CloseAsync to reach the transport parks here while holding the
|
||||||
|
// session's close lock; later callers queue on the lock behind it.
|
||||||
|
transport.CloseSessionHook = async () =>
|
||||||
|
{
|
||||||
|
firstCloseEntered.Release();
|
||||||
|
await releaseFirstClose.WaitAsync().ConfigureAwait(false);
|
||||||
|
transport.CloseSessionHook = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
|
||||||
|
// Holder enters CloseAsync, acquires the lock, and parks in the hook.
|
||||||
|
Task holder = Task.Run(() => session.CloseAsync());
|
||||||
|
await firstCloseEntered.WaitAsync();
|
||||||
|
|
||||||
|
// Waiter is parked on the close lock behind the holder.
|
||||||
|
Task waiter = Task.Run(() => session.CloseAsync());
|
||||||
|
|
||||||
|
// DisposeAsync runs concurrently; it must wait out both callers before
|
||||||
|
// disposing the close lock rather than tearing it down underneath them.
|
||||||
|
Task dispose = session.DisposeAsync().AsTask();
|
||||||
|
|
||||||
|
releaseFirstClose.Release();
|
||||||
|
|
||||||
|
await holder;
|
||||||
|
await waiter;
|
||||||
|
await dispose;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that invoke retries safe diagnostic commands on transient RPC failure.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_RetriesSafeDiagnosticCommandOnTransientGrpcFailure()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.InvokeExceptions.Enqueue(CreateTransientRpcException());
|
||||||
|
transport.AddInvokeReply(new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Kind = MxCommandKind.Ping,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
});
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
|
||||||
|
await session.InvokeAsync(new MxCommandRequest
|
||||||
|
{
|
||||||
|
SessionId = session.SessionId,
|
||||||
|
Command = new MxCommand { Kind = MxCommandKind.Ping, Ping = new PingCommand() },
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(2, transport.InvokeCalls.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that the retry pipeline still retries when the transport maps the raw
|
||||||
|
/// <see cref="RpcException"/> to an <see cref="MxGatewayException"/> before it reaches
|
||||||
|
/// the retry predicate — the wrapped-exception shape that production always produces.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_RetriesSafeDiagnosticCommand_WhenTransportMapsRpcException()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.MapTransportExceptions = true;
|
||||||
|
transport.InvokeExceptions.Enqueue(CreateTransientRpcException());
|
||||||
|
transport.AddInvokeReply(new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Kind = MxCommandKind.Ping,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
});
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
|
||||||
|
await session.InvokeAsync(new MxCommandRequest
|
||||||
|
{
|
||||||
|
SessionId = session.SessionId,
|
||||||
|
Command = new MxCommand { Kind = MxCommandKind.Ping, Ping = new PingCommand() },
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(2, transport.InvokeCalls.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that open session does not retry on transient RPC failure.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenSessionAsync_DoesNotRetryTransientGrpcFailure()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.OpenSessionExceptions.Enqueue(CreateTransientRpcException());
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<RpcException>(async () => await client.OpenSessionAsync());
|
||||||
|
|
||||||
|
Assert.Single(transport.OpenSessionCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that invoke does not retry write commands on transient RPC failure.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_DoesNotRetryWriteCommand()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.InvokeExceptions.Enqueue(CreateTransientRpcException());
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<RpcException>(async () =>
|
||||||
|
await session.WriteRawAsync(1, 2, 3.ToMxValue(), userId: 0));
|
||||||
|
|
||||||
|
Assert.Single(transport.InvokeCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that invoke helpers pass cancellation token to the transport.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeHelpers_PassCancellationTokenToTransport()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource cancellation = new();
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.AddInvokeReply(new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Kind = MxCommandKind.Advise,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
});
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
|
||||||
|
await session.AdviseAsync(12, 34, cancellation.Token);
|
||||||
|
|
||||||
|
Assert.Equal(cancellation.Token, Assert.Single(transport.InvokeCalls).CallOptions.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that a client-imposed <see cref="StatusCode.DeadlineExceeded"/> is not
|
||||||
|
/// retried. The deadline budget is shared across the whole safe-unary operation, so
|
||||||
|
/// an immediate retry would only fail again — the call must surface the failure.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_DoesNotRetrySafeDiagnosticCommand_OnDeadlineExceeded()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.InvokeExceptions.Enqueue(
|
||||||
|
new RpcException(new Status(StatusCode.DeadlineExceeded, "deadline exceeded")));
|
||||||
|
transport.AddInvokeReply(new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Kind = MxCommandKind.Ping,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
});
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<RpcException>(async () => await session.InvokeAsync(
|
||||||
|
new MxCommandRequest
|
||||||
|
{
|
||||||
|
SessionId = session.SessionId,
|
||||||
|
Command = new MxCommand { Kind = MxCommandKind.Ping, Ping = new PingCommand() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
Assert.Single(transport.InvokeCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that a successful register reply missing the typed <c>register</c>
|
||||||
|
/// payload throws a descriptive <see cref="MxGatewayException"/> rather than
|
||||||
|
/// silently returning a zero server handle.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RegisterAsync_Throws_WhenSuccessfulReplyMissingPayload()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.AddInvokeReply(new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Kind = MxCommandKind.Register,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
});
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
|
||||||
|
MxGatewayException exception = await Assert.ThrowsAsync<MxGatewayException>(
|
||||||
|
async () => await session.RegisterAsync("client-name"));
|
||||||
|
|
||||||
|
Assert.Contains("register", exception.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that a successful add-item reply missing the typed <c>add_item</c>
|
||||||
|
/// payload throws a descriptive <see cref="MxGatewayException"/> rather than
|
||||||
|
/// silently returning a zero item handle.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task AddItemAsync_Throws_WhenSuccessfulReplyMissingPayload()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.AddInvokeReply(new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Kind = MxCommandKind.AddItem,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
});
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
|
||||||
|
MxGatewayException exception = await Assert.ThrowsAsync<MxGatewayException>(
|
||||||
|
async () => await session.AddItemAsync(1, "Area.Pump.Speed"));
|
||||||
|
|
||||||
|
Assert.Contains("add_item", exception.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxGatewayClient CreateClient(FakeGatewayTransport transport)
|
||||||
|
{
|
||||||
|
return new MxGatewayClient(transport.Options, transport);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FakeGatewayTransport CreateTransport()
|
||||||
|
{
|
||||||
|
return new FakeGatewayTransport(new MxGatewayClientOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost:5000"),
|
||||||
|
ApiKey = "test-api-key",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RpcException CreateTransientRpcException()
|
||||||
|
{
|
||||||
|
return new RpcException(new Status(StatusCode.Unavailable, "gateway unavailable"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
public sealed class MxGatewayGeneratedContractTests
|
||||||
|
{
|
||||||
|
/// <summary>Verifies that the generated gRPC client can be instantiated from the client factory.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GeneratedGrpcClient_CanBeConstructedFromClientFactory()
|
||||||
|
{
|
||||||
|
var options = new MxGatewayClientOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost:5000"),
|
||||||
|
ApiKey = "test-api-key",
|
||||||
|
};
|
||||||
|
|
||||||
|
await using var client = MxGatewayClient.Create(options);
|
||||||
|
|
||||||
|
Assert.NotNull(client.RawClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Google.Protobuf;
|
||||||
|
using MxGateway.Client;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
public sealed class MxStatusProxyExtensionsTests
|
||||||
|
{
|
||||||
|
/// <summary>Verifies that fixture statuses correctly project success and preserve raw integer fields.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void FixtureStatuses_ProjectSuccessAndPreserveRawFields()
|
||||||
|
{
|
||||||
|
using JsonDocument document = JsonDocument.Parse(ReadFixture(
|
||||||
|
"statuses",
|
||||||
|
"status-conversion-cases.json"));
|
||||||
|
|
||||||
|
foreach (JsonElement testCase in document.RootElement.GetProperty("cases").EnumerateArray())
|
||||||
|
{
|
||||||
|
MxStatusProxy status = JsonParser.Default.Parse<MxStatusProxy>(
|
||||||
|
testCase.GetProperty("status").GetRawText());
|
||||||
|
int success = testCase.GetProperty("status").GetProperty("success").GetInt32();
|
||||||
|
|
||||||
|
Assert.Equal(success != 0 && status.Category is MxStatusCategory.Ok, status.IsSuccess());
|
||||||
|
Assert.Equal(
|
||||||
|
testCase.GetProperty("status").GetProperty("rawCategory").GetInt32(),
|
||||||
|
status.RawCategory);
|
||||||
|
Assert.Equal(
|
||||||
|
testCase.GetProperty("status").GetProperty("rawDetectedBy").GetInt32(),
|
||||||
|
status.RawDetectedBy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadFixture(string category, string fileName)
|
||||||
|
{
|
||||||
|
DirectoryInfo directory = new(AppContext.BaseDirectory);
|
||||||
|
while (directory is not null)
|
||||||
|
{
|
||||||
|
string path = Path.Combine(
|
||||||
|
directory.FullName,
|
||||||
|
"clients",
|
||||||
|
"proto",
|
||||||
|
"fixtures",
|
||||||
|
"behavior",
|
||||||
|
category,
|
||||||
|
fileName);
|
||||||
|
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
return File.ReadAllText(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
directory = directory.Parent!;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FileNotFoundException(fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Google.Protobuf;
|
||||||
|
using MxGateway.Client;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
public sealed class MxValueExtensionsTests
|
||||||
|
{
|
||||||
|
/// <summary>Verifies that scalar values are converted to correctly-typed MxValue protobuf messages.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void ToMxValue_WithScalarValues_CreatesTypedProtobufValues()
|
||||||
|
{
|
||||||
|
Assert.Equal(MxValue.KindOneofCase.BoolValue, true.ToMxValue().KindCase);
|
||||||
|
Assert.Equal(MxValue.KindOneofCase.Int32Value, 123.ToMxValue().KindCase);
|
||||||
|
Assert.Equal(MxValue.KindOneofCase.Int64Value, 123L.ToMxValue().KindCase);
|
||||||
|
Assert.Equal(MxValue.KindOneofCase.FloatValue, 1.25F.ToMxValue().KindCase);
|
||||||
|
Assert.Equal(MxValue.KindOneofCase.DoubleValue, 2.5D.ToMxValue().KindCase);
|
||||||
|
Assert.Equal(MxValue.KindOneofCase.StringValue, "alpha".ToMxValue().KindCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that array values are converted to array-kind MxValue messages with correct element types and dimensions.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void ToMxValue_WithArrays_CreatesTypedArrayProtobufValues()
|
||||||
|
{
|
||||||
|
MxValue value = new[] { "alpha", "beta" }.ToMxValue();
|
||||||
|
|
||||||
|
Assert.Equal(MxValue.KindOneofCase.ArrayValue, value.KindCase);
|
||||||
|
Assert.Equal(MxArray.ValuesOneofCase.StringValues, value.ArrayValue.ValuesCase);
|
||||||
|
Assert.Equal(["alpha", "beta"], value.ArrayValue.StringValues.Values);
|
||||||
|
Assert.Equal([2U], value.ArrayValue.Dimensions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that fixture test cases project to expected MxValue kinds and preserve raw type metadata.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void FixtureValues_ProjectExpectedKindsAndPreserveRawMetadata()
|
||||||
|
{
|
||||||
|
using JsonDocument document = JsonDocument.Parse(ReadFixture(
|
||||||
|
"values",
|
||||||
|
"value-conversion-cases.json"));
|
||||||
|
|
||||||
|
foreach (JsonElement testCase in document.RootElement.GetProperty("cases").EnumerateArray())
|
||||||
|
{
|
||||||
|
string expectedKind = testCase.GetProperty("expectedKind").GetString()!;
|
||||||
|
MxValue value = JsonParser.Default.Parse<MxValue>(
|
||||||
|
testCase.GetProperty("value").GetRawText());
|
||||||
|
|
||||||
|
Assert.Equal(expectedKind, value.GetProjectionKind());
|
||||||
|
|
||||||
|
if (testCase.GetProperty("id").GetString() is "raw-fallback.variant")
|
||||||
|
{
|
||||||
|
Assert.Equal(32767, value.RawDataType);
|
||||||
|
Assert.Equal([1, 2, 3, 4, 5], Assert.IsType<byte[]>(value.ToClrValue()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadFixture(string category, string fileName)
|
||||||
|
{
|
||||||
|
DirectoryInfo directory = new(AppContext.BaseDirectory);
|
||||||
|
while (directory is not null)
|
||||||
|
{
|
||||||
|
string path = Path.Combine(
|
||||||
|
directory.FullName,
|
||||||
|
"clients",
|
||||||
|
"proto",
|
||||||
|
"fixtures",
|
||||||
|
"behavior",
|
||||||
|
category,
|
||||||
|
fileName);
|
||||||
|
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
return File.ReadAllText(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
directory = directory.Parent!;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FileNotFoundException(fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
|
||||||
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
/// <summary>Tests for the shared gRPC-to-native exception mapping used by the transports.</summary>
|
||||||
|
public sealed class RpcExceptionMapperTests
|
||||||
|
{
|
||||||
|
/// <summary>Verifies that an unauthenticated status maps to the authentication exception.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Map_UnauthenticatedStatus_ProducesAuthenticationException()
|
||||||
|
{
|
||||||
|
RpcException rpc = new(new Status(StatusCode.Unauthenticated, "no key"));
|
||||||
|
|
||||||
|
Exception mapped = RpcExceptionMapper.Map(rpc, CancellationToken.None);
|
||||||
|
|
||||||
|
MxGatewayAuthenticationException authentication =
|
||||||
|
Assert.IsType<MxGatewayAuthenticationException>(mapped);
|
||||||
|
Assert.Equal(StatusCode.Unauthenticated, authentication.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a permission-denied status maps to the authorization exception.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Map_PermissionDeniedStatus_ProducesAuthorizationException()
|
||||||
|
{
|
||||||
|
RpcException rpc = new(new Status(StatusCode.PermissionDenied, "missing scope"));
|
||||||
|
|
||||||
|
Exception mapped = RpcExceptionMapper.Map(rpc, CancellationToken.None);
|
||||||
|
|
||||||
|
MxGatewayAuthorizationException authorization =
|
||||||
|
Assert.IsType<MxGatewayAuthorizationException>(mapped);
|
||||||
|
Assert.Equal(StatusCode.PermissionDenied, authorization.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a cancelled status maps to OperationCanceledException.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Map_CancelledStatus_ProducesOperationCanceledException()
|
||||||
|
{
|
||||||
|
RpcException rpc = new(new Status(StatusCode.Cancelled, "cancelled"));
|
||||||
|
|
||||||
|
Exception mapped = RpcExceptionMapper.Map(rpc, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<OperationCanceledException>(mapped);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that non-auth statuses surface the originating gRPC status code on the
|
||||||
|
/// mapped exception so callers can distinguish transient from permanent failures
|
||||||
|
/// without reflecting into InnerException.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(StatusCode.NotFound)]
|
||||||
|
[InlineData(StatusCode.InvalidArgument)]
|
||||||
|
[InlineData(StatusCode.ResourceExhausted)]
|
||||||
|
[InlineData(StatusCode.FailedPrecondition)]
|
||||||
|
[InlineData(StatusCode.Unavailable)]
|
||||||
|
[InlineData(StatusCode.Internal)]
|
||||||
|
public void Map_NonAuthStatus_CarriesStatusCodeOnMxGatewayException(StatusCode statusCode)
|
||||||
|
{
|
||||||
|
RpcException rpc = new(new Status(statusCode, "boom"));
|
||||||
|
|
||||||
|
Exception mapped = RpcExceptionMapper.Map(rpc, CancellationToken.None);
|
||||||
|
|
||||||
|
MxGatewayException gatewayException = Assert.IsType<MxGatewayException>(mapped);
|
||||||
|
Assert.Equal(statusCode, gatewayException.StatusCode);
|
||||||
|
Assert.Same(rpc, gatewayException.InnerException);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an MxGatewayException built without a gRPC status reports a null StatusCode.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void StatusCode_IsNull_WhenNoGrpcStatusProvided()
|
||||||
|
{
|
||||||
|
MxGatewayException gatewayException = new("plain failure");
|
||||||
|
|
||||||
|
Assert.Null(gatewayException.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.0.31903.59
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Client", "MxGateway.Client\MxGateway.Client.csproj", "{7CF9ED88-1F32-4040-BEB1-D0902E304C70}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Contracts", "..\..\src\MxGateway.Contracts\MxGateway.Contracts.csproj", "{9AB807A8-0469-40F7-A000-D240F36B6E5D}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Client.Cli", "MxGateway.Client.Cli\MxGateway.Client.Cli.csproj", "{EB061E77-2475-4322-9257-3F2456DD141C}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Client.Tests", "MxGateway.Client.Tests\MxGateway.Client.Tests.csproj", "{B77B5A8E-0C53-4419-9BCD-227C9753A074}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Debug|x64 = Debug|x64
|
||||||
|
Debug|x86 = Debug|x86
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
Release|x64 = Release|x64
|
||||||
|
Release|x86 = Release|x86
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-side filters and shape options for
|
||||||
|
/// <see cref="GalaxyRepositoryClient.DiscoverHierarchyAsync(DiscoverHierarchyOptions, System.Threading.CancellationToken)"/>.
|
||||||
|
/// Each property maps directly to the corresponding field on the
|
||||||
|
/// <c>DiscoverHierarchyRequest</c> proto so the gateway can narrow the
|
||||||
|
/// hierarchy walk before serializing it back to the client.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record DiscoverHierarchyOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Root Galaxy object id to start the walk from. When set, takes
|
||||||
|
/// precedence over <see cref="RootTagName"/> and <see cref="RootContainedPath"/>.
|
||||||
|
/// </summary>
|
||||||
|
public int? RootGobjectId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Root tag (assigned) name to start the walk from. Used when
|
||||||
|
/// <see cref="RootGobjectId"/> is null.
|
||||||
|
/// </summary>
|
||||||
|
public string? RootTagName { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Root contained-name dotted path to start the walk from. Used when
|
||||||
|
/// neither <see cref="RootGobjectId"/> nor <see cref="RootTagName"/> are set.
|
||||||
|
/// </summary>
|
||||||
|
public string? RootContainedPath { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum traversal depth below the root, inclusive. Leave null for the
|
||||||
|
/// server default (unbounded).
|
||||||
|
/// </summary>
|
||||||
|
public int? MaxDepth { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Galaxy category ids to include. Empty means all categories.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<int> CategoryIds { get; init; } = Array.Empty<int>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Template tag names that must appear somewhere in each returned
|
||||||
|
/// object's template chain. Empty means no template filter.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> TemplateChainContains { get; init; } = Array.Empty<string>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional glob (e.g. <c>"Tank*"</c>) matched against each object's tag name.
|
||||||
|
/// </summary>
|
||||||
|
public string? TagNameGlob { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When set, overrides whether each returned <c>GalaxyObject</c> includes
|
||||||
|
/// its dynamic attribute list. Leave null to use the server default.
|
||||||
|
/// </summary>
|
||||||
|
public bool? IncludeAttributes { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When true, restrict results to objects that bear at least one configured alarm.
|
||||||
|
/// </summary>
|
||||||
|
public bool AlarmBearingOnly { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When true, restrict results to objects that have at least one historized attribute.
|
||||||
|
/// </summary>
|
||||||
|
public bool HistorizedOnly { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
using Grpc.Core;
|
||||||
|
using Grpc.Net.Client;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
using Polly;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Security;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides the .NET client entry point for the public Galaxy Repository gRPC API.
|
||||||
|
/// All RPCs are read-only metadata calls that share the gateway's API-key auth
|
||||||
|
/// interceptor and require the <c>metadata:read</c> scope server-side.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private const int DiscoverHierarchyPageSize = 5000;
|
||||||
|
|
||||||
|
private readonly GrpcChannel? _channel;
|
||||||
|
private readonly IGalaxyRepositoryClientTransport _transport;
|
||||||
|
private readonly ResiliencePipeline _safeUnaryRetryPipeline;
|
||||||
|
private int _disposed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a Galaxy Repository client with custom transport and options.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">Client options.</param>
|
||||||
|
/// <param name="transport">The underlying gRPC transport.</param>
|
||||||
|
internal GalaxyRepositoryClient(
|
||||||
|
MxGatewayClientOptions options,
|
||||||
|
IGalaxyRepositoryClientTransport transport)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
options.Validate();
|
||||||
|
|
||||||
|
Options = options;
|
||||||
|
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
||||||
|
_safeUnaryRetryPipeline = MxGatewayClientRetryPolicy.Create(
|
||||||
|
options.Retry,
|
||||||
|
options.LoggerFactory?.CreateLogger<GalaxyRepositoryClient>());
|
||||||
|
_channel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private GalaxyRepositoryClient(
|
||||||
|
GrpcChannel channel,
|
||||||
|
IGalaxyRepositoryClientTransport transport)
|
||||||
|
{
|
||||||
|
_channel = channel;
|
||||||
|
_transport = transport;
|
||||||
|
Options = transport.Options;
|
||||||
|
_safeUnaryRetryPipeline = MxGatewayClientRetryPolicy.Create(
|
||||||
|
Options.Retry,
|
||||||
|
Options.LoggerFactory?.CreateLogger<GalaxyRepositoryClient>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client options used to configure timeouts, authentication, and retry policy.
|
||||||
|
/// </summary>
|
||||||
|
public MxGatewayClientOptions Options { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The underlying generated gRPC client for advanced operations.
|
||||||
|
/// </summary>
|
||||||
|
public GalaxyRepository.GalaxyRepositoryClient RawClient =>
|
||||||
|
_transport.RawClient
|
||||||
|
?? throw new InvalidOperationException("The raw generated gRPC client is not available for this client instance.");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a Galaxy Repository client with the given options, establishing a new gRPC channel.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">Client options.</param>
|
||||||
|
/// <returns>A new client instance.</returns>
|
||||||
|
public static GalaxyRepositoryClient Create(MxGatewayClientOptions options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
options.Validate();
|
||||||
|
|
||||||
|
HttpMessageHandler handler = CreateHttpHandler(options);
|
||||||
|
var channel = GrpcChannel.ForAddress(
|
||||||
|
options.Endpoint,
|
||||||
|
new GrpcChannelOptions
|
||||||
|
{
|
||||||
|
HttpHandler = handler,
|
||||||
|
LoggerFactory = options.LoggerFactory,
|
||||||
|
MaxReceiveMessageSize = options.MaxGrpcMessageBytes,
|
||||||
|
MaxSendMessageSize = options.MaxGrpcMessageBytes,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new GalaxyRepositoryClient(
|
||||||
|
channel,
|
||||||
|
new GrpcGalaxyRepositoryClientTransport(
|
||||||
|
options,
|
||||||
|
new GalaxyRepository.GalaxyRepositoryClient(channel)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Probes the Galaxy Repository database connection. Returns true when the
|
||||||
|
/// gateway can reach the configured ZB SQL Server.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>True if connection is successful, false otherwise.</returns>
|
||||||
|
public async Task<bool> TestConnectionAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
TestConnectionReply reply = await TestConnectionRawAsync(
|
||||||
|
new TestConnectionRequest(),
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return reply.Ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Probes the Galaxy Repository database connection without result wrapping.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The test connection request.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The raw server reply.</returns>
|
||||||
|
public Task<TestConnectionReply> TestConnectionRawAsync(
|
||||||
|
TestConnectionRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
return ExecuteSafeUnaryAsync(
|
||||||
|
token => _transport.TestConnectionAsync(request, CreateCallOptions(token)),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the timestamp of the most recent Galaxy deployment, or
|
||||||
|
/// <see langword="null"/> when no deployment has been recorded.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The deployment timestamp, or null if not recorded.</returns>
|
||||||
|
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
GetLastDeployTimeReply reply = await GetLastDeployTimeRawAsync(
|
||||||
|
new GetLastDeployTimeRequest(),
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!reply.Present || reply.TimeOfLastDeploy is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.TimeOfLastDeploy.ToDateTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the most recent Galaxy deployment timestamp without result wrapping.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The last deploy-time request.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The raw server reply.</returns>
|
||||||
|
public Task<GetLastDeployTimeReply> GetLastDeployTimeRawAsync(
|
||||||
|
GetLastDeployTimeRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
return ExecuteSafeUnaryAsync(
|
||||||
|
token => _transport.GetLastDeployTimeAsync(request, CreateCallOptions(token)),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enumerates the deployed Galaxy object hierarchy. Each <see cref="GalaxyObject"/>
|
||||||
|
/// includes its dynamic attributes so callers can determine which tag references
|
||||||
|
/// they may subscribe to via the MxAccessGateway service.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The collection of Galaxy objects in the hierarchy.</returns>
|
||||||
|
public async Task<IReadOnlyList<GalaxyObject>> DiscoverHierarchyAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await DiscoverHierarchyAsync(new DiscoverHierarchyOptions(), cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enumerates the deployed Galaxy object hierarchy with caller-supplied
|
||||||
|
/// server-side filters. Each returned <see cref="GalaxyObject"/> may include
|
||||||
|
/// its dynamic attributes (controlled by <see cref="DiscoverHierarchyOptions.IncludeAttributes"/>),
|
||||||
|
/// so callers can determine which tag references they may subscribe to via
|
||||||
|
/// the MxAccessGateway service. The client transparently follows the
|
||||||
|
/// gateway's pagination cursor until the hierarchy is fully drained.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">Server-side filter and shape options.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The filtered collection of Galaxy objects.</returns>
|
||||||
|
public async Task<IReadOnlyList<GalaxyObject>> DiscoverHierarchyAsync(
|
||||||
|
DiscoverHierarchyOptions options,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
List<GalaxyObject> objects = [];
|
||||||
|
HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
|
||||||
|
string pageToken = string.Empty;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
DiscoverHierarchyRequest request = CreateDiscoverHierarchyRequest(options);
|
||||||
|
request.PageSize = DiscoverHierarchyPageSize;
|
||||||
|
request.PageToken = pageToken;
|
||||||
|
DiscoverHierarchyReply reply = await DiscoverHierarchyRawAsync(
|
||||||
|
request,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
objects.AddRange(reply.Objects);
|
||||||
|
pageToken = reply.NextPageToken;
|
||||||
|
if (!string.IsNullOrWhiteSpace(pageToken)
|
||||||
|
&& !seenPageTokens.Add(pageToken))
|
||||||
|
{
|
||||||
|
throw new MxGatewayException(
|
||||||
|
$"Galaxy DiscoverHierarchy returned a repeated page token '{pageToken}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (!string.IsNullOrWhiteSpace(pageToken));
|
||||||
|
|
||||||
|
return objects;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DiscoverHierarchyRequest CreateDiscoverHierarchyRequest(DiscoverHierarchyOptions options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
|
DiscoverHierarchyRequest request = new()
|
||||||
|
{
|
||||||
|
AlarmBearingOnly = options.AlarmBearingOnly,
|
||||||
|
HistorizedOnly = options.HistorizedOnly,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.RootGobjectId.HasValue)
|
||||||
|
{
|
||||||
|
request.RootGobjectId = options.RootGobjectId.Value;
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(options.RootTagName))
|
||||||
|
{
|
||||||
|
request.RootTagName = options.RootTagName;
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(options.RootContainedPath))
|
||||||
|
{
|
||||||
|
request.RootContainedPath = options.RootContainedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.MaxDepth.HasValue)
|
||||||
|
{
|
||||||
|
request.MaxDepth = options.MaxDepth.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
request.CategoryIds.Add(options.CategoryIds);
|
||||||
|
request.TemplateChainContains.Add(options.TemplateChainContains);
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.TagNameGlob))
|
||||||
|
{
|
||||||
|
request.TagNameGlob = options.TagNameGlob;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.IncludeAttributes.HasValue)
|
||||||
|
{
|
||||||
|
request.IncludeAttributes = options.IncludeAttributes.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enumerates the Galaxy object hierarchy without result wrapping.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The discover-hierarchy request.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The raw server reply.</returns>
|
||||||
|
public Task<DiscoverHierarchyReply> DiscoverHierarchyRawAsync(
|
||||||
|
DiscoverHierarchyRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
return ExecuteSafeUnaryAsync(
|
||||||
|
token => _transport.DiscoverHierarchyAsync(request, CreateCallOptions(token)),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subscribes to Galaxy deploy events. The server emits a bootstrap event with the
|
||||||
|
/// current state on subscribe so callers can prime their cache, then emits one event
|
||||||
|
/// per new <c>time_of_last_deploy</c>. Pass <paramref name="lastSeenDeployTime"/> to
|
||||||
|
/// suppress the bootstrap when the caller already holds the current deploy time.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Streaming RPCs are not wrapped by the unary safe-read retry pipeline. If the
|
||||||
|
/// stream is interrupted the caller must reopen it; the server does not guarantee
|
||||||
|
/// at-least-once delivery beyond the per-subscriber buffer (gaps in
|
||||||
|
/// <see cref="DeployEvent.Sequence"/> indicate dropped events).
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="lastSeenDeployTime">Optional timestamp to suppress the bootstrap event.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>An async enumerable of deploy events.</returns>
|
||||||
|
public IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
||||||
|
DateTimeOffset? lastSeenDeployTime = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
WatchDeployEventsRequest request = new();
|
||||||
|
if (lastSeenDeployTime is { } seen)
|
||||||
|
{
|
||||||
|
request.LastSeenDeployTime = Timestamp.FromDateTimeOffset(seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
return WatchDeployEventsRawAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subscribes to Galaxy deploy events without result wrapping.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The watch-deploy-events request.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>An async enumerable of raw deploy events.</returns>
|
||||||
|
public IAsyncEnumerable<DeployEvent> WatchDeployEventsRawAsync(
|
||||||
|
WatchDeployEventsRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
return WatchDeployEventsCoreAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async IAsyncEnumerable<DeployEvent> WatchDeployEventsCoreAsync(
|
||||||
|
WatchDeployEventsRequest request,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await foreach (DeployEvent deployEvent in _transport
|
||||||
|
.WatchDeployEventsAsync(request, CreateStreamCallOptions(cancellationToken))
|
||||||
|
.WithCancellation(cancellationToken)
|
||||||
|
.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
yield return deployEvent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes the gRPC channel and releases resources.
|
||||||
|
/// </summary>
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||||
|
{
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
_channel?.Dispose();
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates gRPC call options with the client's default timeout and API-key authorization.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The call options.</returns>
|
||||||
|
internal CallOptions CreateCallOptions(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return CreateCallOptions(cancellationToken, Options.DefaultCallTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates gRPC call options for streaming RPCs with the stream timeout and API-key authorization.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The stream call options.</returns>
|
||||||
|
internal CallOptions CreateStreamCallOptions(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return CreateCallOptions(cancellationToken, Options.StreamTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates gRPC call options with the specified timeout and API-key authorization.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <param name="timeout">Optional timeout duration.</param>
|
||||||
|
/// <returns>The call options.</returns>
|
||||||
|
internal CallOptions CreateCallOptions(
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
TimeSpan? timeout)
|
||||||
|
{
|
||||||
|
Metadata headers = new()
|
||||||
|
{
|
||||||
|
{ "authorization", $"Bearer {Options.ApiKey}" },
|
||||||
|
};
|
||||||
|
|
||||||
|
return new CallOptions(
|
||||||
|
headers,
|
||||||
|
timeout is null ? null : DateTime.UtcNow.Add(timeout.Value),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T> ExecuteSafeUnaryAsync<T>(
|
||||||
|
Func<CancellationToken, Task<T>> call,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
timeout.CancelAfter(Options.DefaultCallTimeout);
|
||||||
|
|
||||||
|
return await _safeUnaryRetryPipeline.ExecuteAsync(
|
||||||
|
async token => await call(token).ConfigureAwait(false),
|
||||||
|
timeout.Token)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options)
|
||||||
|
{
|
||||||
|
SocketsHttpHandler handler = new()
|
||||||
|
{
|
||||||
|
ConnectTimeout = options.ConnectTimeout,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.UseTls)
|
||||||
|
{
|
||||||
|
handler.SslOptions = new SslClientAuthenticationOptions();
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.ServerNameOverride))
|
||||||
|
{
|
||||||
|
handler.SslOptions.TargetHost = options.ServerNameOverride;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.CaCertificatePath))
|
||||||
|
{
|
||||||
|
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
|
||||||
|
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
|
||||||
|
{
|
||||||
|
if (certificate is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
using X509Chain customChain = new();
|
||||||
|
customChain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||||
|
customChain.ChainPolicy.CustomTrustStore.Add(trustedRoot);
|
||||||
|
customChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||||
|
customChain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
|
||||||
|
X509Certificate2 certificateToValidate = certificate as X509Certificate2
|
||||||
|
?? X509CertificateLoader.LoadCertificate(certificate.Export(X509ContentType.Cert));
|
||||||
|
return customChain.Build(certificateToValidate);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ThrowIfDisposed()
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// gRPC implementation of IGalaxyRepositoryClientTransport.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class GrpcGalaxyRepositoryClientTransport(
|
||||||
|
MxGatewayClientOptions options,
|
||||||
|
GalaxyRepository.GalaxyRepositoryClient rawClient) : IGalaxyRepositoryClientTransport
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the gateway client options.
|
||||||
|
/// </summary>
|
||||||
|
public MxGatewayClientOptions Options { get; } = options;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the underlying gRPC client.
|
||||||
|
/// </summary>
|
||||||
|
public GalaxyRepository.GalaxyRepositoryClient RawClient { get; } = rawClient;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
GalaxyRepository.GalaxyRepositoryClient? IGalaxyRepositoryClientTransport.RawClient => RawClient;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<TestConnectionReply> TestConnectionAsync(
|
||||||
|
TestConnectionRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await RawClient.TestConnectionAsync(request, callOptions)
|
||||||
|
.ResponseAsync
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (RpcException exception)
|
||||||
|
{
|
||||||
|
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
|
||||||
|
GetLastDeployTimeRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await RawClient.GetLastDeployTimeAsync(request, callOptions)
|
||||||
|
.ResponseAsync
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (RpcException exception)
|
||||||
|
{
|
||||||
|
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
|
||||||
|
DiscoverHierarchyRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await RawClient.DiscoverHierarchyAsync(request, callOptions)
|
||||||
|
.ResponseAsync
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (RpcException exception)
|
||||||
|
{
|
||||||
|
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
||||||
|
WatchDeployEventsRequest request,
|
||||||
|
CallOptions callOptions,
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
CancellationToken effectiveCancellationToken = cancellationToken.CanBeCanceled
|
||||||
|
? cancellationToken
|
||||||
|
: callOptions.CancellationToken;
|
||||||
|
|
||||||
|
using AsyncServerStreamingCall<DeployEvent> call = RawClient.WatchDeployEvents(request, callOptions);
|
||||||
|
|
||||||
|
IAsyncStreamReader<DeployEvent> responseStream = call.ResponseStream;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
DeployEvent? deployEvent;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!await responseStream.MoveNext(effectiveCancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
deployEvent = responseStream.Current;
|
||||||
|
}
|
||||||
|
catch (RpcException exception)
|
||||||
|
{
|
||||||
|
throw RpcExceptionMapper.Map(exception, effectiveCancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return deployEvent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
IAsyncEnumerable<DeployEvent> IGalaxyRepositoryClientTransport.WatchDeployEventsAsync(
|
||||||
|
WatchDeployEventsRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
return WatchDeployEventsAsync(request, callOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// gRPC implementation of IMxGatewayClientTransport.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class GrpcMxGatewayClientTransport(
|
||||||
|
MxGatewayClientOptions options,
|
||||||
|
MxAccessGateway.MxAccessGatewayClient rawClient) : IMxGatewayClientTransport
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the gateway client options.
|
||||||
|
/// </summary>
|
||||||
|
public MxGatewayClientOptions Options { get; } = options;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the underlying gRPC client.
|
||||||
|
/// </summary>
|
||||||
|
public MxAccessGateway.MxAccessGatewayClient RawClient { get; } = rawClient;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
MxAccessGateway.MxAccessGatewayClient? IMxGatewayClientTransport.RawClient => RawClient;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<OpenSessionReply> OpenSessionAsync(
|
||||||
|
OpenSessionRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await RawClient.OpenSessionAsync(request, callOptions)
|
||||||
|
.ResponseAsync
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (RpcException exception)
|
||||||
|
{
|
||||||
|
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<CloseSessionReply> CloseSessionAsync(
|
||||||
|
CloseSessionRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await RawClient.CloseSessionAsync(request, callOptions)
|
||||||
|
.ResponseAsync
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (RpcException exception)
|
||||||
|
{
|
||||||
|
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<MxCommandReply> InvokeAsync(
|
||||||
|
MxCommandRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await RawClient.InvokeAsync(request, callOptions)
|
||||||
|
.ResponseAsync
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (RpcException exception)
|
||||||
|
{
|
||||||
|
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
|
StreamEventsRequest request,
|
||||||
|
CallOptions callOptions,
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
CancellationToken effectiveCancellationToken = cancellationToken.CanBeCanceled
|
||||||
|
? cancellationToken
|
||||||
|
: callOptions.CancellationToken;
|
||||||
|
|
||||||
|
using AsyncServerStreamingCall<MxEvent> call = RawClient.StreamEvents(request, callOptions);
|
||||||
|
|
||||||
|
IAsyncStreamReader<MxEvent> responseStream = call.ResponseStream;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
MxEvent? gatewayEvent;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!await responseStream.MoveNext(effectiveCancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
gatewayEvent = responseStream.Current;
|
||||||
|
}
|
||||||
|
catch (RpcException exception)
|
||||||
|
{
|
||||||
|
throw RpcExceptionMapper.Map(exception, effectiveCancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return gatewayEvent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
IAsyncEnumerable<MxEvent> IMxGatewayClientTransport.StreamEventsAsync(
|
||||||
|
StreamEventsRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
return StreamEventsAsync(request, callOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||||
|
AcknowledgeAlarmRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await RawClient.AcknowledgeAlarmAsync(request, callOptions)
|
||||||
|
.ResponseAsync
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (RpcException exception)
|
||||||
|
{
|
||||||
|
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||||
|
StreamAlarmsRequest request,
|
||||||
|
CallOptions callOptions,
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
CancellationToken effectiveCancellationToken = cancellationToken.CanBeCanceled
|
||||||
|
? cancellationToken
|
||||||
|
: callOptions.CancellationToken;
|
||||||
|
|
||||||
|
using AsyncServerStreamingCall<AlarmFeedMessage> call = RawClient.StreamAlarms(request, callOptions);
|
||||||
|
|
||||||
|
IAsyncStreamReader<AlarmFeedMessage> responseStream = call.ResponseStream;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
AlarmFeedMessage? message;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!await responseStream.MoveNext(effectiveCancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
message = responseStream.Current;
|
||||||
|
}
|
||||||
|
catch (RpcException exception)
|
||||||
|
{
|
||||||
|
throw RpcExceptionMapper.Map(exception, effectiveCancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
IAsyncEnumerable<AlarmFeedMessage> IMxGatewayClientTransport.StreamAlarmsAsync(
|
||||||
|
StreamAlarmsRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
return StreamAlarmsAsync(request, callOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>Transport layer for Galaxy Repository gRPC operations.</summary>
|
||||||
|
internal interface IGalaxyRepositoryClientTransport
|
||||||
|
{
|
||||||
|
/// <summary>Gets the client options used to configure this transport.</summary>
|
||||||
|
MxGatewayClientOptions Options { get; }
|
||||||
|
|
||||||
|
/// <summary>Gets the underlying gRPC client, or <c>null</c> if not yet initialized.</summary>
|
||||||
|
GalaxyRepository.GalaxyRepositoryClient? RawClient { get; }
|
||||||
|
|
||||||
|
/// <summary>Tests the connection to the Galaxy Repository server.</summary>
|
||||||
|
/// <param name="request">The test connection request.</param>
|
||||||
|
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
||||||
|
Task<TestConnectionReply> TestConnectionAsync(
|
||||||
|
TestConnectionRequest request,
|
||||||
|
CallOptions callOptions);
|
||||||
|
|
||||||
|
/// <summary>Gets the last deploy time from the Galaxy Repository server.</summary>
|
||||||
|
/// <param name="request">The get last deploy time request.</param>
|
||||||
|
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
||||||
|
Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
|
||||||
|
GetLastDeployTimeRequest request,
|
||||||
|
CallOptions callOptions);
|
||||||
|
|
||||||
|
/// <summary>Discovers the object hierarchy in the Galaxy Repository.</summary>
|
||||||
|
/// <param name="request">The discover hierarchy request.</param>
|
||||||
|
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
||||||
|
Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
|
||||||
|
DiscoverHierarchyRequest request,
|
||||||
|
CallOptions callOptions);
|
||||||
|
|
||||||
|
/// <summary>Watches for deployment events from the Galaxy Repository server.</summary>
|
||||||
|
/// <param name="request">The watch deploy events request.</param>
|
||||||
|
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
||||||
|
IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
||||||
|
WatchDeployEventsRequest request,
|
||||||
|
CallOptions callOptions);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
internal interface IMxGatewayClientTransport
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the client configuration options.
|
||||||
|
/// </summary>
|
||||||
|
MxGatewayClientOptions Options { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the underlying gRPC client, if available.
|
||||||
|
/// </summary>
|
||||||
|
MxAccessGateway.MxAccessGatewayClient? RawClient { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens a new gateway session.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">Session open request.</param>
|
||||||
|
/// <param name="callOptions">gRPC call options.</param>
|
||||||
|
/// <returns>The session open reply.</returns>
|
||||||
|
Task<OpenSessionReply> OpenSessionAsync(
|
||||||
|
OpenSessionRequest request,
|
||||||
|
CallOptions callOptions);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes an open gateway session.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">Session close request.</param>
|
||||||
|
/// <param name="callOptions">gRPC call options.</param>
|
||||||
|
/// <returns>The session close reply.</returns>
|
||||||
|
Task<CloseSessionReply> CloseSessionAsync(
|
||||||
|
CloseSessionRequest request,
|
||||||
|
CallOptions callOptions);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invokes an MXAccess command on the session.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The command request.</param>
|
||||||
|
/// <param name="callOptions">gRPC call options.</param>
|
||||||
|
/// <returns>The command reply.</returns>
|
||||||
|
Task<MxCommandReply> InvokeAsync(
|
||||||
|
MxCommandRequest request,
|
||||||
|
CallOptions callOptions);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Streams events from the session.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The stream events request.</param>
|
||||||
|
/// <param name="callOptions">gRPC call options.</param>
|
||||||
|
/// <returns>An async enumerable of events.</returns>
|
||||||
|
IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
|
StreamEventsRequest request,
|
||||||
|
CallOptions callOptions);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Acknowledges an active MXAccess alarm condition.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The acknowledge request.</param>
|
||||||
|
/// <param name="callOptions">gRPC call options.</param>
|
||||||
|
/// <returns>The acknowledge reply with native MxStatus.</returns>
|
||||||
|
Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||||
|
AcknowledgeAlarmRequest request,
|
||||||
|
CallOptions callOptions);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attaches to the gateway's central alarm feed — the current active-alarm
|
||||||
|
/// snapshot followed by live transitions.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The stream request, optionally scoped by alarm-reference prefix.</param>
|
||||||
|
/// <param name="callOptions">gRPC call options.</param>
|
||||||
|
/// <returns>An async enumerable of alarm feed messages.</returns>
|
||||||
|
IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||||
|
StreamAlarmsRequest request,
|
||||||
|
CallOptions callOptions);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>Exception thrown when an MXAccess command fails with a non-zero HResult or failing status.</summary>
|
||||||
|
public sealed class MxAccessException : MxGatewayCommandException
|
||||||
|
{
|
||||||
|
/// <summary>Initializes a new instance with the given message, reply, and optional inner exception.</summary>
|
||||||
|
/// <param name="message">The error message describing the MXAccess failure.</param>
|
||||||
|
/// <param name="reply">The MxCommandReply containing the failure details (statuses, HResult, etc.).</param>
|
||||||
|
/// <param name="innerException">The underlying exception, if any.</param>
|
||||||
|
public MxAccessException(
|
||||||
|
string message,
|
||||||
|
MxCommandReply reply,
|
||||||
|
Exception? innerException = null)
|
||||||
|
: base(
|
||||||
|
message,
|
||||||
|
reply.SessionId,
|
||||||
|
reply.CorrelationId,
|
||||||
|
reply.ProtocolStatus,
|
||||||
|
reply.HasHresult ? reply.Hresult : null,
|
||||||
|
reply.Statuses.ToArray(),
|
||||||
|
innerException)
|
||||||
|
{
|
||||||
|
Reply = reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets the underlying MxCommandReply containing full failure details.</summary>
|
||||||
|
public MxCommandReply Reply { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>Extension methods for checking MxCommandReply success conditions.</summary>
|
||||||
|
public static class MxCommandReplyExtensions
|
||||||
|
{
|
||||||
|
/// <summary>Validates that the reply has a successful protocol status (Ok or MxAccessFailure), throwing a gateway exception if not.</summary>
|
||||||
|
/// <param name="reply">The command reply to check.</param>
|
||||||
|
public static MxCommandReply EnsureProtocolSuccess(this MxCommandReply reply)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(reply);
|
||||||
|
|
||||||
|
ProtocolStatusCode code = reply.ProtocolStatus?.Code
|
||||||
|
?? ProtocolStatusCode.Unspecified;
|
||||||
|
|
||||||
|
if (code is ProtocolStatusCode.Ok or ProtocolStatusCode.MxaccessFailure)
|
||||||
|
{
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw CreateProtocolException(reply, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Validates that the reply indicates MXAccess success (no HResult or status failures), throwing MxAccessException if not.</summary>
|
||||||
|
/// <param name="reply">The command reply to check.</param>
|
||||||
|
public static MxCommandReply EnsureMxAccessSuccess(this MxCommandReply reply)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(reply);
|
||||||
|
|
||||||
|
bool mxAccessFailure = reply.ProtocolStatus?.Code is ProtocolStatusCode.MxaccessFailure;
|
||||||
|
bool hResultFailure = reply.HasHresult && reply.Hresult != 0;
|
||||||
|
bool statusFailure = reply.Statuses.Any(status => !status.IsSuccess());
|
||||||
|
|
||||||
|
if (!mxAccessFailure && !hResultFailure && !statusFailure)
|
||||||
|
{
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new MxAccessException(CreateMxAccessMessage(reply), reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxGatewayException CreateProtocolException(
|
||||||
|
MxCommandReply reply,
|
||||||
|
ProtocolStatusCode code)
|
||||||
|
{
|
||||||
|
string message = CreateProtocolMessage(reply);
|
||||||
|
int? hResult = reply.HasHresult ? reply.Hresult : null;
|
||||||
|
MxStatusProxy[] statuses = reply.Statuses.ToArray();
|
||||||
|
|
||||||
|
return code switch
|
||||||
|
{
|
||||||
|
ProtocolStatusCode.SessionNotFound or ProtocolStatusCode.SessionNotReady
|
||||||
|
=> new MxGatewaySessionException(
|
||||||
|
message,
|
||||||
|
reply.SessionId,
|
||||||
|
reply.CorrelationId,
|
||||||
|
reply.ProtocolStatus,
|
||||||
|
hResult,
|
||||||
|
statuses),
|
||||||
|
ProtocolStatusCode.WorkerUnavailable
|
||||||
|
=> new MxGatewayWorkerException(
|
||||||
|
message,
|
||||||
|
reply.SessionId,
|
||||||
|
reply.CorrelationId,
|
||||||
|
reply.ProtocolStatus,
|
||||||
|
hResult,
|
||||||
|
statuses),
|
||||||
|
_
|
||||||
|
=> new MxGatewayCommandException(
|
||||||
|
message,
|
||||||
|
reply.SessionId,
|
||||||
|
reply.CorrelationId,
|
||||||
|
reply.ProtocolStatus,
|
||||||
|
hResult,
|
||||||
|
statuses),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateProtocolMessage(MxCommandReply reply)
|
||||||
|
{
|
||||||
|
string statusMessage = string.IsNullOrWhiteSpace(reply.ProtocolStatus?.Message)
|
||||||
|
? "Gateway protocol failure."
|
||||||
|
: reply.ProtocolStatus.Message;
|
||||||
|
|
||||||
|
return $"{statusMessage} code={reply.ProtocolStatus?.Code}; session={reply.SessionId}; correlation={reply.CorrelationId}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateMxAccessMessage(MxCommandReply reply)
|
||||||
|
{
|
||||||
|
string statusSummary = reply.Statuses.Count is 0
|
||||||
|
? "no MXSTATUS_PROXY entries"
|
||||||
|
: string.Join("; ", reply.Statuses.Select(status => status.ToDiagnosticSummary()));
|
||||||
|
|
||||||
|
string hResult = reply.HasHresult
|
||||||
|
? $"0x{reply.Hresult:X8}"
|
||||||
|
: "none";
|
||||||
|
|
||||||
|
return $"MXAccess command failed. kind={reply.Kind}; hresult={hResult}; statuses={statusSummary}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\src\MxGateway.Contracts\MxGateway.Contracts.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Grpc.Net.Client" Version="2.76.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
||||||
|
<PackageReference Include="Polly.Core" Version="8.6.6" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>Exception thrown when an API key is invalid, expired, or malformed.</summary>
|
||||||
|
public sealed class MxGatewayAuthenticationException : MxGatewayException
|
||||||
|
{
|
||||||
|
/// <summary>Initializes a new instance with the given details.</summary>
|
||||||
|
/// <param name="message">The error message describing the authentication failure.</param>
|
||||||
|
/// <param name="sessionId">The session ID, if available.</param>
|
||||||
|
/// <param name="correlationId">The correlation ID for tracing, if available.</param>
|
||||||
|
/// <param name="protocolStatus">The protocol status details, if available.</param>
|
||||||
|
/// <param name="hResult">The HResult code, if available.</param>
|
||||||
|
/// <param name="statuses">The MXAccess statuses, if available.</param>
|
||||||
|
/// <param name="innerException">The underlying exception, if any.</param>
|
||||||
|
/// <param name="statusCode">The gRPC status code reported by the failed call, if available.</param>
|
||||||
|
public MxGatewayAuthenticationException(
|
||||||
|
string message,
|
||||||
|
string? sessionId = null,
|
||||||
|
string? correlationId = null,
|
||||||
|
ProtocolStatus? protocolStatus = null,
|
||||||
|
int? hResult = null,
|
||||||
|
IReadOnlyList<MxStatusProxy>? statuses = null,
|
||||||
|
Exception? innerException = null,
|
||||||
|
StatusCode? statusCode = null)
|
||||||
|
: base(
|
||||||
|
message,
|
||||||
|
sessionId,
|
||||||
|
correlationId,
|
||||||
|
protocolStatus,
|
||||||
|
hResult,
|
||||||
|
statuses ?? [],
|
||||||
|
innerException,
|
||||||
|
statusCode)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>Exception thrown when the API key lacks required scopes for an operation.</summary>
|
||||||
|
public sealed class MxGatewayAuthorizationException : MxGatewayException
|
||||||
|
{
|
||||||
|
/// <summary>Initializes a new instance with the given details.</summary>
|
||||||
|
/// <param name="message">The error message describing the authorization failure.</param>
|
||||||
|
/// <param name="sessionId">The session ID, if available.</param>
|
||||||
|
/// <param name="correlationId">The correlation ID for tracing, if available.</param>
|
||||||
|
/// <param name="protocolStatus">The protocol status details, if available.</param>
|
||||||
|
/// <param name="hResult">The HResult code, if available.</param>
|
||||||
|
/// <param name="statuses">The MXAccess statuses, if available.</param>
|
||||||
|
/// <param name="innerException">The underlying exception, if any.</param>
|
||||||
|
/// <param name="statusCode">The gRPC status code reported by the failed call, if available.</param>
|
||||||
|
public MxGatewayAuthorizationException(
|
||||||
|
string message,
|
||||||
|
string? sessionId = null,
|
||||||
|
string? correlationId = null,
|
||||||
|
ProtocolStatus? protocolStatus = null,
|
||||||
|
int? hResult = null,
|
||||||
|
IReadOnlyList<MxStatusProxy>? statuses = null,
|
||||||
|
Exception? innerException = null,
|
||||||
|
StatusCode? statusCode = null)
|
||||||
|
: base(
|
||||||
|
message,
|
||||||
|
sessionId,
|
||||||
|
correlationId,
|
||||||
|
protocolStatus,
|
||||||
|
hResult,
|
||||||
|
statuses ?? [],
|
||||||
|
innerException,
|
||||||
|
statusCode)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using Grpc.Net.Client;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using Polly;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Security;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides the .NET client entry point for the public MXAccess Gateway gRPC API.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MxGatewayClient : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly GrpcChannel _channel;
|
||||||
|
private readonly IMxGatewayClientTransport _transport;
|
||||||
|
private readonly ResiliencePipeline _safeUnaryRetryPipeline;
|
||||||
|
private int _disposed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="MxGatewayClient"/> with given options and transport.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">Client configuration options.</param>
|
||||||
|
/// <param name="transport">Transport implementation for gateway communication.</param>
|
||||||
|
internal MxGatewayClient(
|
||||||
|
MxGatewayClientOptions options,
|
||||||
|
IMxGatewayClientTransport transport)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
options.Validate();
|
||||||
|
|
||||||
|
Options = options;
|
||||||
|
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
||||||
|
_safeUnaryRetryPipeline = MxGatewayClientRetryPolicy.Create(
|
||||||
|
options.Retry,
|
||||||
|
options.LoggerFactory?.CreateLogger<MxGatewayClient>());
|
||||||
|
_channel = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MxGatewayClient(
|
||||||
|
GrpcChannel channel,
|
||||||
|
IMxGatewayClientTransport transport)
|
||||||
|
{
|
||||||
|
_channel = channel;
|
||||||
|
_transport = transport;
|
||||||
|
Options = transport.Options;
|
||||||
|
_safeUnaryRetryPipeline = MxGatewayClientRetryPolicy.Create(
|
||||||
|
Options.Retry,
|
||||||
|
Options.LoggerFactory?.CreateLogger<MxGatewayClient>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the client configuration options.
|
||||||
|
/// </summary>
|
||||||
|
public MxGatewayClientOptions Options { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the underlying generated gRPC client.
|
||||||
|
/// </summary>
|
||||||
|
public MxAccessGateway.MxAccessGatewayClient RawClient =>
|
||||||
|
_transport.RawClient
|
||||||
|
?? throw new InvalidOperationException("The raw generated gRPC client is not available for this client instance.");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new gateway client with the given options.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">Client configuration options.</param>
|
||||||
|
/// <returns>A new gateway client instance.</returns>
|
||||||
|
public static MxGatewayClient Create(MxGatewayClientOptions options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
options.Validate();
|
||||||
|
|
||||||
|
HttpMessageHandler handler = CreateHttpHandler(options);
|
||||||
|
var channel = GrpcChannel.ForAddress(
|
||||||
|
options.Endpoint,
|
||||||
|
new GrpcChannelOptions
|
||||||
|
{
|
||||||
|
HttpHandler = handler,
|
||||||
|
LoggerFactory = options.LoggerFactory,
|
||||||
|
MaxReceiveMessageSize = options.MaxGrpcMessageBytes,
|
||||||
|
MaxSendMessageSize = options.MaxGrpcMessageBytes,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new MxGatewayClient(
|
||||||
|
channel,
|
||||||
|
new GrpcMxGatewayClientTransport(
|
||||||
|
options,
|
||||||
|
new MxAccessGateway.MxAccessGatewayClient(channel)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens a new gateway session.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">Session open request; defaults to empty request if null.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||||
|
/// <returns>A wrapped gateway session.</returns>
|
||||||
|
public async Task<MxGatewaySession> OpenSessionAsync(
|
||||||
|
OpenSessionRequest? request = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
OpenSessionReply reply = await OpenSessionRawAsync(
|
||||||
|
request ?? new OpenSessionRequest(),
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return new MxGatewaySession(this, reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens a new gateway session and returns the raw protobuf reply.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">Session open request.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||||
|
/// <returns>The raw gateway session open reply.</returns>
|
||||||
|
public Task<OpenSessionReply> OpenSessionRawAsync(
|
||||||
|
OpenSessionRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
return _transport.OpenSessionAsync(request, CreateCallOptions(cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes an open gateway session.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">Session close request.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||||
|
/// <returns>The session close reply.</returns>
|
||||||
|
public Task<CloseSessionReply> CloseSessionRawAsync(
|
||||||
|
CloseSessionRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
return ExecuteSafeUnaryAsync(
|
||||||
|
token => _transport.CloseSessionAsync(request, CreateCallOptions(token)),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invokes an MXAccess command on the open session.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The command request.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||||
|
/// <returns>The command reply.</returns>
|
||||||
|
public Task<MxCommandReply> InvokeAsync(
|
||||||
|
MxCommandRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
if (MxGatewayClientRetryPolicy.IsRetryableCommand(request.Command?.Kind ?? MxCommandKind.Unspecified))
|
||||||
|
{
|
||||||
|
return ExecuteSafeUnaryAsync(
|
||||||
|
token => _transport.InvokeAsync(request, CreateCallOptions(token)),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _transport.InvokeAsync(request, CreateCallOptions(cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Streams events from the gateway session.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The stream events request.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||||
|
/// <returns>An async enumerable of events.</returns>
|
||||||
|
public IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
|
StreamEventsRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
return _transport.StreamEventsAsync(request, CreateStreamCallOptions(cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Acknowledges an active MXAccess alarm condition through the gateway. The
|
||||||
|
/// gateway authorizes <see cref="AcknowledgeAlarmRequest"/> against the API
|
||||||
|
/// key's <c>admin</c> scope (there is no finer-grained alarm-ack sub-scope)
|
||||||
|
/// and forwards the acknowledge to the worker's MXAccess session; the
|
||||||
|
/// resulting <see cref="MxStatusProxy"/> is returned in the reply.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The acknowledge request — alarm reference, comment, operator user.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||||
|
/// <returns>The acknowledge reply with protocol + native MxStatus.</returns>
|
||||||
|
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||||
|
AcknowledgeAlarmRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
return ExecuteSafeUnaryAsync(
|
||||||
|
token => _transport.AcknowledgeAlarmAsync(request, CreateCallOptions(token)),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attaches to the gateway's central alarm feed. The stream opens with one
|
||||||
|
/// <see cref="AlarmFeedMessage"/> per currently-active alarm (the
|
||||||
|
/// ConditionRefresh snapshot), then a single <c>snapshot_complete</c>, then a
|
||||||
|
/// <c>transition</c> for every subsequent raise / acknowledge / clear. Served
|
||||||
|
/// by the gateway's always-on alarm monitor — no worker session is opened, so
|
||||||
|
/// any number of clients may attach. Optionally scoped by alarm-reference
|
||||||
|
/// prefix (<see cref="StreamAlarmsRequest.AlarmFilterPrefix"/>).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The stream request, optionally scoped by alarm-reference prefix.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token for the stream.</param>
|
||||||
|
/// <returns>An async enumerable of alarm feed messages.</returns>
|
||||||
|
public IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||||
|
StreamAlarmsRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
return _transport.StreamAlarmsAsync(request, CreateStreamCallOptions(cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Disposes the client and releases all resources.
|
||||||
|
/// </summary>
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||||
|
{
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
_channel?.Dispose();
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates gRPC call options with default timeout and authorization.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Cancellation token for the call.</param>
|
||||||
|
/// <returns>Configured call options.</returns>
|
||||||
|
internal CallOptions CreateCallOptions(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return CreateCallOptions(cancellationToken, Options.DefaultCallTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates gRPC call options for streaming with stream timeout and authorization.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Cancellation token for the call.</param>
|
||||||
|
/// <returns>Configured call options.</returns>
|
||||||
|
internal CallOptions CreateStreamCallOptions(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return CreateCallOptions(cancellationToken, Options.StreamTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates gRPC call options with specified timeout and authorization.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Cancellation token for the call.</param>
|
||||||
|
/// <param name="timeout">Optional timeout duration; null means no timeout.</param>
|
||||||
|
/// <returns>Configured call options.</returns>
|
||||||
|
internal CallOptions CreateCallOptions(
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
TimeSpan? timeout)
|
||||||
|
{
|
||||||
|
Metadata headers = new()
|
||||||
|
{
|
||||||
|
{ "authorization", $"Bearer {Options.ApiKey}" },
|
||||||
|
};
|
||||||
|
|
||||||
|
return new CallOptions(
|
||||||
|
headers,
|
||||||
|
timeout is null ? null : DateTime.UtcNow.Add(timeout.Value),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T> ExecuteSafeUnaryAsync<T>(
|
||||||
|
Func<CancellationToken, Task<T>> call,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
timeout.CancelAfter(Options.DefaultCallTimeout);
|
||||||
|
|
||||||
|
return await _safeUnaryRetryPipeline.ExecuteAsync(
|
||||||
|
async token => await call(token).ConfigureAwait(false),
|
||||||
|
timeout.Token)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options)
|
||||||
|
{
|
||||||
|
SocketsHttpHandler handler = new()
|
||||||
|
{
|
||||||
|
ConnectTimeout = options.ConnectTimeout,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.UseTls)
|
||||||
|
{
|
||||||
|
handler.SslOptions = new SslClientAuthenticationOptions();
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.ServerNameOverride))
|
||||||
|
{
|
||||||
|
handler.SslOptions.TargetHost = options.ServerNameOverride;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.CaCertificatePath))
|
||||||
|
{
|
||||||
|
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
|
||||||
|
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
|
||||||
|
{
|
||||||
|
if (certificate is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
using X509Chain customChain = new();
|
||||||
|
customChain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||||
|
customChain.ChainPolicy.CustomTrustStore.Add(trustedRoot);
|
||||||
|
customChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||||
|
customChain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
|
||||||
|
X509Certificate2 certificateToValidate = certificate as X509Certificate2
|
||||||
|
?? X509CertificateLoader.LoadCertificate(certificate.Export(X509ContentType.Cert));
|
||||||
|
return customChain.Build(certificateToValidate);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ThrowIfDisposed()
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using MxGateway.Contracts;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exposes the protocol versions compiled into this client package.
|
||||||
|
/// </summary>
|
||||||
|
public static class MxGatewayClientContractInfo
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the gateway gRPC protocol version compiled into this client package.
|
||||||
|
/// A client and gateway are wire-compatible only when this value matches the
|
||||||
|
/// gateway's advertised gateway protocol version.
|
||||||
|
/// </summary>
|
||||||
|
public const uint GatewayProtocolVersion =
|
||||||
|
GatewayContractInfo.GatewayProtocolVersion;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the worker frame protocol version compiled into this client package.
|
||||||
|
/// Exposed for diagnostics so callers can report the worker protocol the
|
||||||
|
/// shared contracts were generated against.
|
||||||
|
/// </summary>
|
||||||
|
public const uint WorkerProtocolVersion =
|
||||||
|
GatewayContractInfo.WorkerProtocolVersion;
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures the gRPC channel used by the .NET MXAccess Gateway client.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MxGatewayClientOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the gateway endpoint URI (required).
|
||||||
|
/// </summary>
|
||||||
|
public required Uri Endpoint { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the API key for gateway authentication (required).
|
||||||
|
/// </summary>
|
||||||
|
public required string ApiKey { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether to use TLS for the gateway connection.
|
||||||
|
/// </summary>
|
||||||
|
public bool UseTls { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the path to a CA certificate file for custom certificate validation.
|
||||||
|
/// </summary>
|
||||||
|
public string? CaCertificatePath { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the server name override for SNI during TLS handshake.
|
||||||
|
/// </summary>
|
||||||
|
public string? ServerNameOverride { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the timeout for establishing connection to the gateway.
|
||||||
|
/// </summary>
|
||||||
|
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the timeout budget for a unary gRPC operation. This is both the gRPC
|
||||||
|
/// deadline stamped on each individual attempt and the overall budget for the
|
||||||
|
/// whole safe-unary operation: for retryable calls the initial attempt, every
|
||||||
|
/// retry, and the backoff delays between them all share this single budget.
|
||||||
|
/// It is therefore an upper bound on the total wall-clock time a safe-unary
|
||||||
|
/// call can take, not a fresh per-retry allowance.
|
||||||
|
/// </summary>
|
||||||
|
public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the optional timeout for streaming gRPC calls.
|
||||||
|
/// </summary>
|
||||||
|
public TimeSpan? StreamTimeout { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the maximum size, in bytes, of a single gRPC message the client will
|
||||||
|
/// send or receive. Applied to both the send and receive limits of the
|
||||||
|
/// underlying channel. Defaults to 16 MiB.
|
||||||
|
/// </summary>
|
||||||
|
public int MaxGrpcMessageBytes { get; init; } = 16 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the retry configuration for safe unary calls.
|
||||||
|
/// </summary>
|
||||||
|
public MxGatewayClientRetryOptions Retry { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the logger factory for diagnostic logging.
|
||||||
|
/// </summary>
|
||||||
|
public ILoggerFactory? LoggerFactory { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates the client options for consistency and correctness.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="ArgumentNullException">Endpoint is null.</exception>
|
||||||
|
/// <exception cref="ArgumentException">Options are invalid or inconsistent.</exception>
|
||||||
|
/// <exception cref="ArgumentOutOfRangeException">Timeout values are not greater than zero.</exception>
|
||||||
|
public void Validate()
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(Endpoint);
|
||||||
|
|
||||||
|
if (!Endpoint.IsAbsoluteUri)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"The gateway endpoint must be an absolute URI.",
|
||||||
|
nameof(Endpoint));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(ApiKey))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"The gateway API key must not be empty.",
|
||||||
|
nameof(ApiKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ConnectTimeout <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(ConnectTimeout),
|
||||||
|
"The connect timeout must be greater than zero.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DefaultCallTimeout <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(DefaultCallTimeout),
|
||||||
|
"The default call timeout must be greater than zero.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StreamTimeout is not null && StreamTimeout <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(StreamTimeout),
|
||||||
|
"The stream timeout must be greater than zero when configured.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MaxGrpcMessageBytes <= 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(MaxGrpcMessageBytes),
|
||||||
|
"The maximum gRPC message size must be greater than zero.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (UseTls && Endpoint.Scheme != Uri.UriSchemeHttps)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"UseTls requires an https gateway endpoint.",
|
||||||
|
nameof(Endpoint));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!UseTls && Endpoint.Scheme == Uri.UriSchemeHttps)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"An https gateway endpoint requires UseTls.",
|
||||||
|
nameof(Endpoint));
|
||||||
|
}
|
||||||
|
|
||||||
|
Retry.Validate();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>Configuration for automatic retry behavior on transient gRPC call failures.</summary>
|
||||||
|
public sealed class MxGatewayClientRetryOptions
|
||||||
|
{
|
||||||
|
/// <summary>Gets the maximum number of attempts (initial + retries); default is 2.</summary>
|
||||||
|
public int MaxAttempts { get; init; } = 2;
|
||||||
|
|
||||||
|
/// <summary>Gets the initial delay between retry attempts; default is 200 milliseconds.</summary>
|
||||||
|
public TimeSpan Delay { get; init; } = TimeSpan.FromMilliseconds(200);
|
||||||
|
|
||||||
|
/// <summary>Gets the maximum delay between retry attempts; default is 2 seconds.</summary>
|
||||||
|
public TimeSpan MaxDelay { get; init; } = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
|
/// <summary>Gets a value indicating whether to add randomness to retry delays; default is true.</summary>
|
||||||
|
public bool UseJitter { get; init; } = true;
|
||||||
|
|
||||||
|
/// <summary>Validates the retry options and throws if any constraint is violated.</summary>
|
||||||
|
public void Validate()
|
||||||
|
{
|
||||||
|
if (MaxAttempts <= 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(MaxAttempts),
|
||||||
|
"The retry max attempts value must be greater than zero.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Delay <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(Delay),
|
||||||
|
"The retry delay must be greater than zero.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MaxDelay <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(MaxDelay),
|
||||||
|
"The retry max delay must be greater than zero.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MaxDelay < Delay)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(MaxDelay),
|
||||||
|
"The retry max delay must be greater than or equal to the retry delay.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using Polly;
|
||||||
|
using Polly.Retry;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>Factory and helpers for exponential-backoff retry policies on transient gRPC failures.</summary>
|
||||||
|
internal static class MxGatewayClientRetryPolicy
|
||||||
|
{
|
||||||
|
/// <summary>Creates a Polly ResiliencePipeline that retries transient gRPC failures with exponential backoff.</summary>
|
||||||
|
/// <param name="options">Retry configuration (max attempts, delay bounds, jitter).</param>
|
||||||
|
/// <param name="logger">Optional logger for retry diagnostics.</param>
|
||||||
|
public static ResiliencePipeline Create(
|
||||||
|
MxGatewayClientRetryOptions options,
|
||||||
|
ILogger? logger)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
options.Validate();
|
||||||
|
|
||||||
|
return new ResiliencePipelineBuilder()
|
||||||
|
.AddRetry(new RetryStrategyOptions
|
||||||
|
{
|
||||||
|
MaxRetryAttempts = Math.Max(0, options.MaxAttempts - 1),
|
||||||
|
BackoffType = DelayBackoffType.Exponential,
|
||||||
|
UseJitter = options.UseJitter,
|
||||||
|
Delay = options.Delay,
|
||||||
|
MaxDelay = options.MaxDelay,
|
||||||
|
ShouldHandle = new PredicateBuilder().Handle<Exception>(IsTransientGrpcFailure),
|
||||||
|
OnRetry = args =>
|
||||||
|
{
|
||||||
|
logger?.LogDebug(
|
||||||
|
args.Outcome.Exception,
|
||||||
|
"Retrying MXAccess Gateway client call after transient gRPC failure. Attempt {Attempt}.",
|
||||||
|
args.AttemptNumber + 1);
|
||||||
|
return default;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns whether a command kind is eligible for automatic retry on transient failures.</summary>
|
||||||
|
/// <param name="kind">The command kind to check.</param>
|
||||||
|
public static bool IsRetryableCommand(MxCommandKind kind)
|
||||||
|
{
|
||||||
|
return kind is MxCommandKind.Ping
|
||||||
|
or MxCommandKind.GetSessionState
|
||||||
|
or MxCommandKind.GetWorkerInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsTransientGrpcFailure(Exception exception)
|
||||||
|
{
|
||||||
|
return exception switch
|
||||||
|
{
|
||||||
|
RpcException rpcException => IsTransientStatus(rpcException.StatusCode),
|
||||||
|
MxGatewayException { InnerException: RpcException rpcException } => IsTransientStatus(rpcException.StatusCode),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsTransientStatus(StatusCode statusCode)
|
||||||
|
{
|
||||||
|
// DeadlineExceeded is intentionally NOT treated as transient. The deadline
|
||||||
|
// on every unary call is client-imposed (CreateCallOptions stamps the
|
||||||
|
// DefaultCallTimeout budget), and that same budget is shared across the
|
||||||
|
// initial attempt plus all retries plus backoff. A DeadlineExceeded means
|
||||||
|
// the shared budget is exhausted, so an immediate retry would only fail
|
||||||
|
// again — burning the remaining budget on a call that cannot succeed.
|
||||||
|
return statusCode is StatusCode.Unavailable
|
||||||
|
or StatusCode.ResourceExhausted;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>Exception thrown when a gateway command fails due to an unclassified protocol error.</summary>
|
||||||
|
public class MxGatewayCommandException : MxGatewayException
|
||||||
|
{
|
||||||
|
/// <summary>Initializes a new instance with the given details.</summary>
|
||||||
|
/// <param name="message">The error message describing the command failure.</param>
|
||||||
|
/// <param name="sessionId">The session ID, if available.</param>
|
||||||
|
/// <param name="correlationId">The correlation ID for tracing, if available.</param>
|
||||||
|
/// <param name="protocolStatus">The protocol status details, if available.</param>
|
||||||
|
/// <param name="hResult">The HResult code, if available.</param>
|
||||||
|
/// <param name="statuses">The MXAccess statuses, if available.</param>
|
||||||
|
/// <param name="innerException">The underlying exception, if any.</param>
|
||||||
|
public MxGatewayCommandException(
|
||||||
|
string message,
|
||||||
|
string? sessionId = null,
|
||||||
|
string? correlationId = null,
|
||||||
|
ProtocolStatus? protocolStatus = null,
|
||||||
|
int? hResult = null,
|
||||||
|
IReadOnlyList<MxStatusProxy>? statuses = null,
|
||||||
|
Exception? innerException = null)
|
||||||
|
: base(
|
||||||
|
message,
|
||||||
|
sessionId,
|
||||||
|
correlationId,
|
||||||
|
protocolStatus,
|
||||||
|
hResult,
|
||||||
|
statuses ?? [],
|
||||||
|
innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exception thrown when a gateway RPC call fails or returns an error status.
|
||||||
|
/// </summary>
|
||||||
|
public class MxGatewayException : Exception
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the MxGatewayException class with the specified message.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="message">Diagnostic message describing the failure.</param>
|
||||||
|
public MxGatewayException(string message)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
Statuses = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the MxGatewayException class with the specified message and inner exception.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="message">Diagnostic message describing the failure.</param>
|
||||||
|
/// <param name="innerException">Underlying exception that caused this failure.</param>
|
||||||
|
public MxGatewayException(string message, Exception? innerException)
|
||||||
|
: base(message, innerException)
|
||||||
|
{
|
||||||
|
Statuses = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the MxGatewayException class carrying the originating
|
||||||
|
/// gRPC status code so callers can distinguish transient from permanent failures.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="message">Diagnostic message describing the failure.</param>
|
||||||
|
/// <param name="statusCode">The gRPC status code reported by the failed call.</param>
|
||||||
|
/// <param name="innerException">Underlying exception that caused this failure.</param>
|
||||||
|
public MxGatewayException(string message, StatusCode statusCode, Exception? innerException)
|
||||||
|
: base(message, innerException)
|
||||||
|
{
|
||||||
|
StatusCode = statusCode;
|
||||||
|
Statuses = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the MxGatewayException class with full diagnostic information.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="message">Diagnostic message describing the failure.</param>
|
||||||
|
/// <param name="sessionId">Session ID associated with the exception, if available.</param>
|
||||||
|
/// <param name="correlationId">Correlation ID associated with the exception, if available.</param>
|
||||||
|
/// <param name="protocolStatus">Protocol-level status returned by the gateway, if available.</param>
|
||||||
|
/// <param name="hResult">HRESULT code returned by the worker or MXAccess, if available.</param>
|
||||||
|
/// <param name="statuses">List of MXAccess status codes returned by the operation.</param>
|
||||||
|
/// <param name="innerException">Underlying exception that caused this failure.</param>
|
||||||
|
/// <param name="statusCode">The gRPC status code reported by the failed call, if available.</param>
|
||||||
|
public MxGatewayException(
|
||||||
|
string message,
|
||||||
|
string? sessionId,
|
||||||
|
string? correlationId,
|
||||||
|
ProtocolStatus? protocolStatus,
|
||||||
|
int? hResult,
|
||||||
|
IReadOnlyList<MxStatusProxy> statuses,
|
||||||
|
Exception? innerException = null,
|
||||||
|
StatusCode? statusCode = null)
|
||||||
|
: base(message, innerException)
|
||||||
|
{
|
||||||
|
SessionId = sessionId;
|
||||||
|
CorrelationId = correlationId;
|
||||||
|
ProtocolStatus = protocolStatus;
|
||||||
|
HResultCode = hResult;
|
||||||
|
Statuses = statuses;
|
||||||
|
StatusCode = statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the session ID associated with the exception, if available.
|
||||||
|
/// </summary>
|
||||||
|
public string? SessionId { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the correlation ID associated with the exception, if available.
|
||||||
|
/// </summary>
|
||||||
|
public string? CorrelationId { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the protocol-level status returned by the gateway, if available.
|
||||||
|
/// </summary>
|
||||||
|
public ProtocolStatus? ProtocolStatus { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the HRESULT code returned by the worker or MXAccess, if available.
|
||||||
|
/// </summary>
|
||||||
|
public int? HResultCode { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the list of MXAccess status codes returned by the operation.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<MxStatusProxy> Statuses { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the gRPC status code reported by the failed call, if the failure originated
|
||||||
|
/// from a gRPC <see cref="RpcException"/>. <see langword="null"/> when the exception
|
||||||
|
/// was not produced from a gRPC status (for example, a protocol-level reply failure).
|
||||||
|
/// Callers can inspect this to distinguish a transient outage
|
||||||
|
/// (<see cref="Grpc.Core.StatusCode.Unavailable"/>) from a permanent error
|
||||||
|
/// (<see cref="Grpc.Core.StatusCode.InvalidArgument"/>) without downcasting
|
||||||
|
/// <see cref="Exception.InnerException"/>.
|
||||||
|
/// </summary>
|
||||||
|
public StatusCode? StatusCode { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,882 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents one gateway-backed MXAccess session.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MxGatewaySession : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly MxGatewayClient _client;
|
||||||
|
private readonly SemaphoreSlim _closeLock = new(1, 1);
|
||||||
|
private readonly object _disposeGate = new();
|
||||||
|
private CloseSessionReply? _closeReply;
|
||||||
|
private int _activeCloseCount;
|
||||||
|
private bool _closeLockDisposed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new session backed by the given MXAccess gateway client.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="client">The gateway client used for commands and events.</param>
|
||||||
|
/// <param name="openSessionReply">The server's session creation response.</param>
|
||||||
|
internal MxGatewaySession(
|
||||||
|
MxGatewayClient client,
|
||||||
|
OpenSessionReply openSessionReply)
|
||||||
|
{
|
||||||
|
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||||
|
OpenSessionReply = openSessionReply ?? throw new ArgumentNullException(nameof(openSessionReply));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The session ID assigned by the gateway.
|
||||||
|
/// </summary>
|
||||||
|
public string SessionId => OpenSessionReply.SessionId;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The server's session creation response containing metadata.
|
||||||
|
/// </summary>
|
||||||
|
public OpenSessionReply OpenSessionReply { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes the session on the gateway. Idempotent.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The server's close-session reply.</returns>
|
||||||
|
public async Task<CloseSessionReply> CloseAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_closeReply is not null)
|
||||||
|
{
|
||||||
|
return _closeReply;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register as an in-flight closer under the dispose gate. DisposeAsync waits for
|
||||||
|
// _activeCloseCount to drain before disposing the close lock, so the semaphore is
|
||||||
|
// guaranteed to outlive every WaitAsync started here.
|
||||||
|
lock (_disposeGate)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_closeLockDisposed, this);
|
||||||
|
_activeCloseCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _closeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_closeReply is not null)
|
||||||
|
{
|
||||||
|
return _closeReply;
|
||||||
|
}
|
||||||
|
|
||||||
|
_closeReply = await _client.CloseSessionRawAsync(
|
||||||
|
new CloseSessionRequest { SessionId = SessionId },
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return _closeReply;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_closeLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
lock (_disposeGate)
|
||||||
|
{
|
||||||
|
_activeCloseCount--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a client with the MXAccess session, returning a ServerHandle.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="clientName">Name to register.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The server handle assigned to the registered client.</returns>
|
||||||
|
public async Task<int> RegisterAsync(
|
||||||
|
string clientName,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
MxCommandReply reply = await RegisterRawAsync(clientName, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
return reply.Register?.ServerHandle
|
||||||
|
?? throw CreateMissingPayloadException(reply, "register");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a client with the MXAccess session without error checking.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="clientName">Name to register.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The raw server reply.</returns>
|
||||||
|
public Task<MxCommandReply> RegisterRawAsync(
|
||||||
|
string clientName,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(clientName);
|
||||||
|
|
||||||
|
return InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Register,
|
||||||
|
Register = new RegisterCommand { ClientName = clientName },
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds an item to the MXAccess session, returning an ItemHandle.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
|
/// <param name="itemDefinition">The item tag address.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The item handle assigned to the new item.</returns>
|
||||||
|
public async Task<int> AddItemAsync(
|
||||||
|
int serverHandle,
|
||||||
|
string itemDefinition,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
MxCommandReply reply = await AddItemRawAsync(
|
||||||
|
serverHandle,
|
||||||
|
itemDefinition,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
return reply.AddItem?.ItemHandle
|
||||||
|
?? throw CreateMissingPayloadException(reply, "add_item");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds an item to the MXAccess session without error checking.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
|
/// <param name="itemDefinition">The item tag address.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The raw server reply.</returns>
|
||||||
|
public Task<MxCommandReply> AddItemRawAsync(
|
||||||
|
int serverHandle,
|
||||||
|
string itemDefinition,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(itemDefinition);
|
||||||
|
|
||||||
|
return InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.AddItem,
|
||||||
|
AddItem = new AddItemCommand
|
||||||
|
{
|
||||||
|
ServerHandle = serverHandle,
|
||||||
|
ItemDefinition = itemDefinition,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds an item with context to the MXAccess session, returning an ItemHandle.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
|
/// <param name="itemDefinition">The item tag address.</param>
|
||||||
|
/// <param name="itemContext">Additional context for the item.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The item handle assigned to the new item.</returns>
|
||||||
|
public async Task<int> AddItem2Async(
|
||||||
|
int serverHandle,
|
||||||
|
string itemDefinition,
|
||||||
|
string itemContext,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
MxCommandReply reply = await AddItem2RawAsync(
|
||||||
|
serverHandle,
|
||||||
|
itemDefinition,
|
||||||
|
itemContext,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
return reply.AddItem2?.ItemHandle
|
||||||
|
?? throw CreateMissingPayloadException(reply, "add_item2");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds an item with context to the MXAccess session without error checking.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
|
/// <param name="itemDefinition">The item tag address.</param>
|
||||||
|
/// <param name="itemContext">Additional context for the item.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The raw server reply.</returns>
|
||||||
|
public Task<MxCommandReply> AddItem2RawAsync(
|
||||||
|
int serverHandle,
|
||||||
|
string itemDefinition,
|
||||||
|
string itemContext,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(itemDefinition);
|
||||||
|
|
||||||
|
return InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.AddItem2,
|
||||||
|
AddItem2 = new AddItem2Command
|
||||||
|
{
|
||||||
|
ServerHandle = serverHandle,
|
||||||
|
ItemDefinition = itemDefinition,
|
||||||
|
ItemContext = itemContext ?? string.Empty,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subscribes to events for an item (advises in MXAccess terminology).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
|
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
public async Task AdviseAsync(
|
||||||
|
int serverHandle,
|
||||||
|
int itemHandle,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
MxCommandReply reply = await AdviseRawAsync(serverHandle, itemHandle, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subscribes to events for an item without error checking.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
|
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The raw server reply.</returns>
|
||||||
|
public Task<MxCommandReply> AdviseRawAsync(
|
||||||
|
int serverHandle,
|
||||||
|
int itemHandle,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Advise,
|
||||||
|
Advise = new AdviseCommand
|
||||||
|
{
|
||||||
|
ServerHandle = serverHandle,
|
||||||
|
ItemHandle = itemHandle,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unsubscribes from events for an item (unadvises in MXAccess terminology).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
|
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
public async Task UnAdviseAsync(
|
||||||
|
int serverHandle,
|
||||||
|
int itemHandle,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
MxCommandReply reply = await UnAdviseRawAsync(serverHandle, itemHandle, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unsubscribes from events for an item without error checking.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
|
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The raw server reply.</returns>
|
||||||
|
public Task<MxCommandReply> UnAdviseRawAsync(
|
||||||
|
int serverHandle,
|
||||||
|
int itemHandle,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.UnAdvise,
|
||||||
|
UnAdvise = new UnAdviseCommand
|
||||||
|
{
|
||||||
|
ServerHandle = serverHandle,
|
||||||
|
ItemHandle = itemHandle,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes an item from the MXAccess session.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
|
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
public async Task RemoveItemAsync(
|
||||||
|
int serverHandle,
|
||||||
|
int itemHandle,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
MxCommandReply reply = await RemoveItemRawAsync(serverHandle, itemHandle, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes an item from the MXAccess session without error checking.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
|
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The raw server reply.</returns>
|
||||||
|
public Task<MxCommandReply> RemoveItemRawAsync(
|
||||||
|
int serverHandle,
|
||||||
|
int itemHandle,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.RemoveItem,
|
||||||
|
RemoveItem = new RemoveItemCommand
|
||||||
|
{
|
||||||
|
ServerHandle = serverHandle,
|
||||||
|
ItemHandle = itemHandle,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds multiple items to the MXAccess session in a single command.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
|
/// <param name="tagAddresses">The item tag addresses to add.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Per-item subscription results.</returns>
|
||||||
|
public async Task<IReadOnlyList<SubscribeResult>> AddItemBulkAsync(
|
||||||
|
int serverHandle,
|
||||||
|
IReadOnlyList<string> tagAddresses,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(tagAddresses);
|
||||||
|
|
||||||
|
AddItemBulkCommand command = new() { ServerHandle = serverHandle };
|
||||||
|
command.TagAddresses.Add(tagAddresses);
|
||||||
|
|
||||||
|
MxCommandReply reply = await InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.AddItemBulk,
|
||||||
|
AddItemBulk = command,
|
||||||
|
},
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
return reply.AddItemBulk?.Results.ToArray() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Advises multiple items in a single command.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
|
/// <param name="itemHandles">The ItemHandles to advise.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Per-item subscription results.</returns>
|
||||||
|
public async Task<IReadOnlyList<SubscribeResult>> AdviseItemBulkAsync(
|
||||||
|
int serverHandle,
|
||||||
|
IReadOnlyList<int> itemHandles,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(itemHandles);
|
||||||
|
|
||||||
|
AdviseItemBulkCommand command = new() { ServerHandle = serverHandle };
|
||||||
|
command.ItemHandles.Add(itemHandles);
|
||||||
|
|
||||||
|
MxCommandReply reply = await InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.AdviseItemBulk,
|
||||||
|
AdviseItemBulk = command,
|
||||||
|
},
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
return reply.AdviseItemBulk?.Results.ToArray() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes multiple items in a single command.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
|
/// <param name="itemHandles">The ItemHandles to remove.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Per-item subscription results.</returns>
|
||||||
|
public async Task<IReadOnlyList<SubscribeResult>> RemoveItemBulkAsync(
|
||||||
|
int serverHandle,
|
||||||
|
IReadOnlyList<int> itemHandles,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(itemHandles);
|
||||||
|
|
||||||
|
RemoveItemBulkCommand command = new() { ServerHandle = serverHandle };
|
||||||
|
command.ItemHandles.Add(itemHandles);
|
||||||
|
|
||||||
|
MxCommandReply reply = await InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.RemoveItemBulk,
|
||||||
|
RemoveItemBulk = command,
|
||||||
|
},
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
return reply.RemoveItemBulk?.Results.ToArray() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unadvises multiple items in a single command.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
|
/// <param name="itemHandles">The ItemHandles to unadvise.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Per-item subscription results.</returns>
|
||||||
|
public async Task<IReadOnlyList<SubscribeResult>> UnAdviseItemBulkAsync(
|
||||||
|
int serverHandle,
|
||||||
|
IReadOnlyList<int> itemHandles,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(itemHandles);
|
||||||
|
|
||||||
|
UnAdviseItemBulkCommand command = new() { ServerHandle = serverHandle };
|
||||||
|
command.ItemHandles.Add(itemHandles);
|
||||||
|
|
||||||
|
MxCommandReply reply = await InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.UnAdviseItemBulk,
|
||||||
|
UnAdviseItemBulk = command,
|
||||||
|
},
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
return reply.UnAdviseItemBulk?.Results.ToArray() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds and advises multiple items in a single command.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
|
/// <param name="tagAddresses">The item tag addresses to add and advise.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Per-item subscription results.</returns>
|
||||||
|
public async Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
|
||||||
|
int serverHandle,
|
||||||
|
IReadOnlyList<string> tagAddresses,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(tagAddresses);
|
||||||
|
|
||||||
|
SubscribeBulkCommand command = new() { ServerHandle = serverHandle };
|
||||||
|
command.TagAddresses.Add(tagAddresses);
|
||||||
|
|
||||||
|
MxCommandReply reply = await InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.SubscribeBulk,
|
||||||
|
SubscribeBulk = command,
|
||||||
|
},
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
return reply.SubscribeBulk?.Results.ToArray() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unadvises and removes multiple items in a single command.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
|
/// <param name="itemHandles">The ItemHandles to unsubscribe.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Per-item subscription results.</returns>
|
||||||
|
public async Task<IReadOnlyList<SubscribeResult>> UnsubscribeBulkAsync(
|
||||||
|
int serverHandle,
|
||||||
|
IReadOnlyList<int> itemHandles,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(itemHandles);
|
||||||
|
|
||||||
|
UnsubscribeBulkCommand command = new() { ServerHandle = serverHandle };
|
||||||
|
command.ItemHandles.Add(itemHandles);
|
||||||
|
|
||||||
|
MxCommandReply reply = await InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.UnsubscribeBulk,
|
||||||
|
UnsubscribeBulk = command,
|
||||||
|
},
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
return reply.UnsubscribeBulk?.Results.ToArray() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulk Write — sequential MXAccess Write per entry on the worker's STA.
|
||||||
|
/// Per-item failures appear as BulkWriteResult entries with
|
||||||
|
/// <c>WasSuccessful = false</c>; the call never throws on per-item errors.
|
||||||
|
/// Protocol-level failures still throw via EnsureProtocolSuccess.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IReadOnlyList<BulkWriteResult>> WriteBulkAsync(
|
||||||
|
int serverHandle,
|
||||||
|
IReadOnlyList<WriteBulkEntry> entries,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(entries);
|
||||||
|
|
||||||
|
WriteBulkCommand command = new() { ServerHandle = serverHandle };
|
||||||
|
command.Entries.Add(entries);
|
||||||
|
|
||||||
|
MxCommandReply reply = await InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.WriteBulk,
|
||||||
|
WriteBulk = command,
|
||||||
|
},
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
return reply.WriteBulk?.Results.ToArray() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Bulk Write2 — sequential MXAccess Write2 (timestamped) per entry.</summary>
|
||||||
|
public async Task<IReadOnlyList<BulkWriteResult>> Write2BulkAsync(
|
||||||
|
int serverHandle,
|
||||||
|
IReadOnlyList<Write2BulkEntry> entries,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(entries);
|
||||||
|
|
||||||
|
Write2BulkCommand command = new() { ServerHandle = serverHandle };
|
||||||
|
command.Entries.Add(entries);
|
||||||
|
|
||||||
|
MxCommandReply reply = await InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Write2Bulk,
|
||||||
|
Write2Bulk = command,
|
||||||
|
},
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
return reply.Write2Bulk?.Results.ToArray() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulk WriteSecured — sequential MXAccess WriteSecured per entry.
|
||||||
|
/// Credential-sensitive values must never reach logs; the client mirrors
|
||||||
|
/// the single-item WriteSecured redaction contract.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IReadOnlyList<BulkWriteResult>> WriteSecuredBulkAsync(
|
||||||
|
int serverHandle,
|
||||||
|
IReadOnlyList<WriteSecuredBulkEntry> entries,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(entries);
|
||||||
|
|
||||||
|
WriteSecuredBulkCommand command = new() { ServerHandle = serverHandle };
|
||||||
|
command.Entries.Add(entries);
|
||||||
|
|
||||||
|
MxCommandReply reply = await InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.WriteSecuredBulk,
|
||||||
|
WriteSecuredBulk = command,
|
||||||
|
},
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
return reply.WriteSecuredBulk?.Results.ToArray() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Bulk WriteSecured2 — sequential MXAccess WriteSecured2 (timestamped) per entry.</summary>
|
||||||
|
public async Task<IReadOnlyList<BulkWriteResult>> WriteSecured2BulkAsync(
|
||||||
|
int serverHandle,
|
||||||
|
IReadOnlyList<WriteSecured2BulkEntry> entries,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(entries);
|
||||||
|
|
||||||
|
WriteSecured2BulkCommand command = new() { ServerHandle = serverHandle };
|
||||||
|
command.Entries.Add(entries);
|
||||||
|
|
||||||
|
MxCommandReply reply = await InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.WriteSecured2Bulk,
|
||||||
|
WriteSecured2Bulk = command,
|
||||||
|
},
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
return reply.WriteSecured2Bulk?.Results.ToArray() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulk Read — snapshot the current value for each requested tag.
|
||||||
|
/// Returns the cached OnDataChange value when the tag is already advised
|
||||||
|
/// (was_cached = true), otherwise the worker takes the full AddItem +
|
||||||
|
/// Advise + wait + UnAdvise + RemoveItem snapshot lifecycle. Per-tag
|
||||||
|
/// failures (timeout, invalid tag) appear as BulkReadResult entries with
|
||||||
|
/// <c>WasSuccessful = false</c>; the call never throws on per-tag errors.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IReadOnlyList<BulkReadResult>> ReadBulkAsync(
|
||||||
|
int serverHandle,
|
||||||
|
IReadOnlyList<string> tagAddresses,
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(tagAddresses);
|
||||||
|
|
||||||
|
ReadBulkCommand command = new()
|
||||||
|
{
|
||||||
|
ServerHandle = serverHandle,
|
||||||
|
TimeoutMs = timeout <= TimeSpan.Zero ? 0u : (uint)Math.Min(timeout.TotalMilliseconds, uint.MaxValue),
|
||||||
|
};
|
||||||
|
command.TagAddresses.Add(tagAddresses);
|
||||||
|
|
||||||
|
MxCommandReply reply = await InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.ReadBulk,
|
||||||
|
ReadBulk = command,
|
||||||
|
},
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
return reply.ReadBulk?.Results.ToArray() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes a value to an item on the MXAccess server.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
|
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||||
|
/// <param name="value">The value to write.</param>
|
||||||
|
/// <param name="userId">User ID context for the write.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
public async Task WriteAsync(
|
||||||
|
int serverHandle,
|
||||||
|
int itemHandle,
|
||||||
|
MxValue value,
|
||||||
|
int userId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
MxCommandReply reply = await WriteRawAsync(serverHandle, itemHandle, value, userId, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes a value to an item on the MXAccess server without error checking.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
|
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||||
|
/// <param name="value">The value to write.</param>
|
||||||
|
/// <param name="userId">User ID context for the write.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The raw server reply.</returns>
|
||||||
|
public Task<MxCommandReply> WriteRawAsync(
|
||||||
|
int serverHandle,
|
||||||
|
int itemHandle,
|
||||||
|
MxValue value,
|
||||||
|
int userId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
|
|
||||||
|
return InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Write,
|
||||||
|
Write = new WriteCommand
|
||||||
|
{
|
||||||
|
ServerHandle = serverHandle,
|
||||||
|
ItemHandle = itemHandle,
|
||||||
|
Value = value,
|
||||||
|
UserId = userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes a value and timestamp to an item on the MXAccess server.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
|
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||||
|
/// <param name="value">The value to write.</param>
|
||||||
|
/// <param name="timestampValue">The timestamp to write with the value.</param>
|
||||||
|
/// <param name="userId">User ID context for the write.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
public async Task Write2Async(
|
||||||
|
int serverHandle,
|
||||||
|
int itemHandle,
|
||||||
|
MxValue value,
|
||||||
|
MxValue timestampValue,
|
||||||
|
int userId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
MxCommandReply reply = await Write2RawAsync(
|
||||||
|
serverHandle,
|
||||||
|
itemHandle,
|
||||||
|
value,
|
||||||
|
timestampValue,
|
||||||
|
userId,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes a value and timestamp to an item on the MXAccess server without error checking.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
|
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||||
|
/// <param name="value">The value to write.</param>
|
||||||
|
/// <param name="timestampValue">The timestamp to write with the value.</param>
|
||||||
|
/// <param name="userId">User ID context for the write.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The raw server reply.</returns>
|
||||||
|
public Task<MxCommandReply> Write2RawAsync(
|
||||||
|
int serverHandle,
|
||||||
|
int itemHandle,
|
||||||
|
MxValue value,
|
||||||
|
MxValue timestampValue,
|
||||||
|
int userId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
|
ArgumentNullException.ThrowIfNull(timestampValue);
|
||||||
|
|
||||||
|
return InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Write2,
|
||||||
|
Write2 = new Write2Command
|
||||||
|
{
|
||||||
|
ServerHandle = serverHandle,
|
||||||
|
ItemHandle = itemHandle,
|
||||||
|
Value = value,
|
||||||
|
TimestampValue = timestampValue,
|
||||||
|
UserId = userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invokes an MXAccess command on this session.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The command request.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The raw server reply.</returns>
|
||||||
|
public Task<MxCommandReply> InvokeAsync(
|
||||||
|
MxCommandRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
return _client.InvokeAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Streams events from the worker for this session, optionally starting after a given sequence number.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="afterWorkerSequence">The sequence number to stream from. Defaults to 0.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>An async enumerable of events.</returns>
|
||||||
|
public IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
|
ulong afterWorkerSequence = 0,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return _client.StreamEventsAsync(
|
||||||
|
new StreamEventsRequest
|
||||||
|
{
|
||||||
|
SessionId = SessionId,
|
||||||
|
AfterWorkerSequence = afterWorkerSequence,
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes the session and releases resources.
|
||||||
|
/// </summary>
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
lock (_disposeGate)
|
||||||
|
{
|
||||||
|
if (_closeLockDisposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await CloseAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Wait for every concurrent CloseAsync caller to leave the close lock before
|
||||||
|
// disposing it; once _closeReply is set those callers return without awaiting.
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
lock (_disposeGate)
|
||||||
|
{
|
||||||
|
if (_activeCloseCount == 0)
|
||||||
|
{
|
||||||
|
_closeLockDisposed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Yield();
|
||||||
|
}
|
||||||
|
|
||||||
|
_closeLock.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<MxCommandReply> InvokeCommandAsync(
|
||||||
|
MxCommand command,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _client.InvokeAsync(
|
||||||
|
new MxCommandRequest
|
||||||
|
{
|
||||||
|
SessionId = SessionId,
|
||||||
|
ClientCorrelationId = Guid.NewGuid().ToString("N"),
|
||||||
|
Command = command,
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the exception thrown when a command reply passed protocol and
|
||||||
|
/// MXAccess success checks but is missing the typed handle-bearing payload
|
||||||
|
/// the command contract requires. Surfacing this as a clear error avoids
|
||||||
|
/// silently handing a zero handle to the caller (it would otherwise fall
|
||||||
|
/// through to <see cref="MxCommandReply.ReturnValue"/>, which is 0 when the
|
||||||
|
/// reply carries no return value).
|
||||||
|
/// </summary>
|
||||||
|
private static MxGatewayException CreateMissingPayloadException(
|
||||||
|
MxCommandReply reply,
|
||||||
|
string expectedPayload)
|
||||||
|
{
|
||||||
|
return new MxGatewayException(
|
||||||
|
$"Gateway reply for command kind={reply.Kind} reported success but is missing "
|
||||||
|
+ $"the required '{expectedPayload}' payload; cannot resolve a handle. "
|
||||||
|
+ $"session={reply.SessionId}; correlation={reply.CorrelationId}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>Exception thrown when a session is not found, not ready, or invalid.</summary>
|
||||||
|
public sealed class MxGatewaySessionException : MxGatewayException
|
||||||
|
{
|
||||||
|
/// <summary>Initializes a new instance with the given details.</summary>
|
||||||
|
/// <param name="message">The error message describing the session failure.</param>
|
||||||
|
/// <param name="sessionId">The session ID, if available.</param>
|
||||||
|
/// <param name="correlationId">The correlation ID for tracing, if available.</param>
|
||||||
|
/// <param name="protocolStatus">The protocol status details, if available.</param>
|
||||||
|
/// <param name="hResult">The HResult code, if available.</param>
|
||||||
|
/// <param name="statuses">The MXAccess statuses, if available.</param>
|
||||||
|
/// <param name="innerException">The underlying exception, if any.</param>
|
||||||
|
public MxGatewaySessionException(
|
||||||
|
string message,
|
||||||
|
string? sessionId = null,
|
||||||
|
string? correlationId = null,
|
||||||
|
ProtocolStatus? protocolStatus = null,
|
||||||
|
int? hResult = null,
|
||||||
|
IReadOnlyList<MxStatusProxy>? statuses = null,
|
||||||
|
Exception? innerException = null)
|
||||||
|
: base(
|
||||||
|
message,
|
||||||
|
sessionId,
|
||||||
|
correlationId,
|
||||||
|
protocolStatus,
|
||||||
|
hResult,
|
||||||
|
statuses ?? [],
|
||||||
|
innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>Exception thrown when the worker process is unavailable or fails to process a command.</summary>
|
||||||
|
public sealed class MxGatewayWorkerException : MxGatewayException
|
||||||
|
{
|
||||||
|
/// <summary>Initializes a new instance with the given details.</summary>
|
||||||
|
/// <param name="message">The error message describing the worker failure.</param>
|
||||||
|
/// <param name="sessionId">The session ID, if available.</param>
|
||||||
|
/// <param name="correlationId">The correlation ID for tracing, if available.</param>
|
||||||
|
/// <param name="protocolStatus">The protocol status details, if available.</param>
|
||||||
|
/// <param name="hResult">The HResult code, if available.</param>
|
||||||
|
/// <param name="statuses">The MXAccess statuses, if available.</param>
|
||||||
|
/// <param name="innerException">The underlying exception, if any.</param>
|
||||||
|
public MxGatewayWorkerException(
|
||||||
|
string message,
|
||||||
|
string? sessionId = null,
|
||||||
|
string? correlationId = null,
|
||||||
|
ProtocolStatus? protocolStatus = null,
|
||||||
|
int? hResult = null,
|
||||||
|
IReadOnlyList<MxStatusProxy>? statuses = null,
|
||||||
|
Exception? innerException = null)
|
||||||
|
: base(
|
||||||
|
message,
|
||||||
|
sessionId,
|
||||||
|
correlationId,
|
||||||
|
protocolStatus,
|
||||||
|
hResult,
|
||||||
|
statuses ?? [],
|
||||||
|
innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>Extension methods for MxStatusProxy values.</summary>
|
||||||
|
public static class MxStatusProxyExtensions
|
||||||
|
{
|
||||||
|
/// <summary>Returns whether the status indicates success (success flag set and category is Ok).</summary>
|
||||||
|
/// <param name="status">The status to check.</param>
|
||||||
|
public static bool IsSuccess(this MxStatusProxy status)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(status);
|
||||||
|
|
||||||
|
return status.Success != 0
|
||||||
|
&& status.Category is MxStatusCategory.Ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns a formatted summary of the status for diagnostic output.</summary>
|
||||||
|
/// <param name="status">The status to summarize.</param>
|
||||||
|
public static string ToDiagnosticSummary(this MxStatusProxy status)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(status);
|
||||||
|
|
||||||
|
string diagnosticText = string.IsNullOrWhiteSpace(status.DiagnosticText)
|
||||||
|
? "no diagnostic text"
|
||||||
|
: status.DiagnosticText;
|
||||||
|
|
||||||
|
return $"{status.Category} by {status.DetectedBy}; detail={status.Detail}; {diagnosticText}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
using Google.Protobuf;
|
||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates and projects gateway MXAccess values without hiding the raw
|
||||||
|
/// protobuf value carried by command replies and events.
|
||||||
|
/// </summary>
|
||||||
|
public static class MxValueExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a boolean value to an MxValue with MxDataType.Boolean.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">Scalar boolean value to wrap.</param>
|
||||||
|
public static MxValue ToMxValue(this bool value)
|
||||||
|
{
|
||||||
|
return new MxValue
|
||||||
|
{
|
||||||
|
DataType = MxDataType.Boolean,
|
||||||
|
VariantType = "VT_BOOL",
|
||||||
|
BoolValue = value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a 32-bit integer value to an MxValue with MxDataType.Integer.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">32-bit integer value to wrap.</param>
|
||||||
|
public static MxValue ToMxValue(this int value)
|
||||||
|
{
|
||||||
|
return new MxValue
|
||||||
|
{
|
||||||
|
DataType = MxDataType.Integer,
|
||||||
|
VariantType = "VT_I4",
|
||||||
|
Int32Value = value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a 64-bit integer value to an MxValue with MxDataType.Integer.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">64-bit integer value to wrap.</param>
|
||||||
|
public static MxValue ToMxValue(this long value)
|
||||||
|
{
|
||||||
|
return new MxValue
|
||||||
|
{
|
||||||
|
DataType = MxDataType.Integer,
|
||||||
|
VariantType = "VT_I8",
|
||||||
|
Int64Value = value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a single-precision floating-point value to an MxValue with MxDataType.Float.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">Single-precision floating-point value to wrap.</param>
|
||||||
|
public static MxValue ToMxValue(this float value)
|
||||||
|
{
|
||||||
|
return new MxValue
|
||||||
|
{
|
||||||
|
DataType = MxDataType.Float,
|
||||||
|
VariantType = "VT_R4",
|
||||||
|
FloatValue = value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a double-precision floating-point value to an MxValue with MxDataType.Double.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">Double-precision floating-point value to wrap.</param>
|
||||||
|
public static MxValue ToMxValue(this double value)
|
||||||
|
{
|
||||||
|
return new MxValue
|
||||||
|
{
|
||||||
|
DataType = MxDataType.Double,
|
||||||
|
VariantType = "VT_R8",
|
||||||
|
DoubleValue = value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a string value to an MxValue with MxDataType.String.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">String value to wrap.</param>
|
||||||
|
public static MxValue ToMxValue(this string value)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
|
|
||||||
|
return new MxValue
|
||||||
|
{
|
||||||
|
DataType = MxDataType.String,
|
||||||
|
VariantType = "VT_BSTR",
|
||||||
|
StringValue = value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a DateTimeOffset value to an MxValue with MxDataType.Time.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">DateTimeOffset value to wrap.</param>
|
||||||
|
public static MxValue ToMxValue(this DateTimeOffset value)
|
||||||
|
{
|
||||||
|
return new MxValue
|
||||||
|
{
|
||||||
|
DataType = MxDataType.Time,
|
||||||
|
VariantType = "VT_DATE",
|
||||||
|
TimestampValue = Timestamp.FromDateTimeOffset(value),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a DateTime value to an MxValue with MxDataType.Time.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">DateTime value to wrap.</param>
|
||||||
|
public static MxValue ToMxValue(this DateTime value)
|
||||||
|
{
|
||||||
|
return new DateTimeOffset(
|
||||||
|
value.Kind == DateTimeKind.Unspecified
|
||||||
|
? DateTime.SpecifyKind(value, DateTimeKind.Utc)
|
||||||
|
: value.ToUniversalTime())
|
||||||
|
.ToMxValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a boolean array to an MxValue with MxDataType.Boolean.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="values">Array of boolean values to wrap.</param>
|
||||||
|
public static MxValue ToMxValue(this IReadOnlyList<bool> values)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
|
|
||||||
|
var array = new BoolArray();
|
||||||
|
array.Values.Add(values);
|
||||||
|
return CreateArrayValue(MxDataType.Boolean, "VT_ARRAY|VT_BOOL", values.Count, new MxArray
|
||||||
|
{
|
||||||
|
ElementDataType = MxDataType.Boolean,
|
||||||
|
VariantType = "VT_ARRAY|VT_BOOL",
|
||||||
|
BoolValues = array,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a 32-bit integer array to an MxValue with MxDataType.Integer.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="values">Array of 32-bit integer values to wrap.</param>
|
||||||
|
public static MxValue ToMxValue(this IReadOnlyList<int> values)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
|
|
||||||
|
var array = new Int32Array();
|
||||||
|
array.Values.Add(values);
|
||||||
|
return CreateArrayValue(MxDataType.Integer, "VT_ARRAY|VT_I4", values.Count, new MxArray
|
||||||
|
{
|
||||||
|
ElementDataType = MxDataType.Integer,
|
||||||
|
VariantType = "VT_ARRAY|VT_I4",
|
||||||
|
Int32Values = array,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a 64-bit integer array to an MxValue with MxDataType.Integer.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="values">Array of 64-bit integer values to wrap.</param>
|
||||||
|
public static MxValue ToMxValue(this IReadOnlyList<long> values)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
|
|
||||||
|
var array = new Int64Array();
|
||||||
|
array.Values.Add(values);
|
||||||
|
return CreateArrayValue(MxDataType.Integer, "VT_ARRAY|VT_I8", values.Count, new MxArray
|
||||||
|
{
|
||||||
|
ElementDataType = MxDataType.Integer,
|
||||||
|
VariantType = "VT_ARRAY|VT_I8",
|
||||||
|
Int64Values = array,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a single-precision floating-point array to an MxValue with MxDataType.Float.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="values">Array of single-precision floating-point values to wrap.</param>
|
||||||
|
public static MxValue ToMxValue(this IReadOnlyList<float> values)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
|
|
||||||
|
var array = new FloatArray();
|
||||||
|
array.Values.Add(values);
|
||||||
|
return CreateArrayValue(MxDataType.Float, "VT_ARRAY|VT_R4", values.Count, new MxArray
|
||||||
|
{
|
||||||
|
ElementDataType = MxDataType.Float,
|
||||||
|
VariantType = "VT_ARRAY|VT_R4",
|
||||||
|
FloatValues = array,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a double-precision floating-point array to an MxValue with MxDataType.Double.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="values">Array of double-precision floating-point values to wrap.</param>
|
||||||
|
public static MxValue ToMxValue(this IReadOnlyList<double> values)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
|
|
||||||
|
var array = new DoubleArray();
|
||||||
|
array.Values.Add(values);
|
||||||
|
return CreateArrayValue(MxDataType.Double, "VT_ARRAY|VT_R8", values.Count, new MxArray
|
||||||
|
{
|
||||||
|
ElementDataType = MxDataType.Double,
|
||||||
|
VariantType = "VT_ARRAY|VT_R8",
|
||||||
|
DoubleValues = array,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a string array to an MxValue with MxDataType.String.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="values">Array of string values to wrap.</param>
|
||||||
|
public static MxValue ToMxValue(this IReadOnlyList<string> values)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
|
|
||||||
|
var array = new StringArray();
|
||||||
|
array.Values.Add(values);
|
||||||
|
return CreateArrayValue(MxDataType.String, "VT_ARRAY|VT_BSTR", values.Count, new MxArray
|
||||||
|
{
|
||||||
|
ElementDataType = MxDataType.String,
|
||||||
|
VariantType = "VT_ARRAY|VT_BSTR",
|
||||||
|
StringValues = array,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a DateTimeOffset array to an MxValue with MxDataType.Time.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="values">Array of DateTimeOffset values to wrap.</param>
|
||||||
|
public static MxValue ToMxValue(this IReadOnlyList<DateTimeOffset> values)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
|
|
||||||
|
var array = new TimestampArray();
|
||||||
|
array.Values.Add(values.Select(Timestamp.FromDateTimeOffset));
|
||||||
|
return CreateArrayValue(MxDataType.Time, "VT_ARRAY|VT_DATE", values.Count, new MxArray
|
||||||
|
{
|
||||||
|
ElementDataType = MxDataType.Time,
|
||||||
|
VariantType = "VT_ARRAY|VT_DATE",
|
||||||
|
TimestampValues = array,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the projection kind (field name) of the given MxValue's current oneof value.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">The MxValue whose oneof projection kind is returned.</param>
|
||||||
|
public static string GetProjectionKind(this MxValue value)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
|
|
||||||
|
return value.KindCase switch
|
||||||
|
{
|
||||||
|
MxValue.KindOneofCase.BoolValue => "boolValue",
|
||||||
|
MxValue.KindOneofCase.Int32Value => "int32Value",
|
||||||
|
MxValue.KindOneofCase.Int64Value => "int64Value",
|
||||||
|
MxValue.KindOneofCase.FloatValue => "floatValue",
|
||||||
|
MxValue.KindOneofCase.DoubleValue => "doubleValue",
|
||||||
|
MxValue.KindOneofCase.StringValue => "stringValue",
|
||||||
|
MxValue.KindOneofCase.TimestampValue => "timestampValue",
|
||||||
|
MxValue.KindOneofCase.ArrayValue => "arrayValue",
|
||||||
|
MxValue.KindOneofCase.RawValue => "rawValue",
|
||||||
|
_ => value.IsNull ? "nullValue" : "unspecified",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts an MxValue to a CLR object; returns the boxed value or null for null MxValues.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">The MxValue to convert.</param>
|
||||||
|
public static object? ToClrValue(this MxValue value)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
|
|
||||||
|
return value.KindCase switch
|
||||||
|
{
|
||||||
|
MxValue.KindOneofCase.BoolValue => value.BoolValue,
|
||||||
|
MxValue.KindOneofCase.Int32Value => value.Int32Value,
|
||||||
|
MxValue.KindOneofCase.Int64Value => value.Int64Value,
|
||||||
|
MxValue.KindOneofCase.FloatValue => value.FloatValue,
|
||||||
|
MxValue.KindOneofCase.DoubleValue => value.DoubleValue,
|
||||||
|
MxValue.KindOneofCase.StringValue => value.StringValue,
|
||||||
|
MxValue.KindOneofCase.TimestampValue => value.TimestampValue.ToDateTimeOffset(),
|
||||||
|
MxValue.KindOneofCase.ArrayValue => value.ArrayValue.ToClrArrayValue(),
|
||||||
|
MxValue.KindOneofCase.RawValue => value.RawValue.ToByteArray(),
|
||||||
|
_ => value.IsNull ? null : value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts an MxArray to a CLR array; returns null if the array does not have a known element type.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="array">The MxArray to convert.</param>
|
||||||
|
public static object? ToClrArrayValue(this MxArray array)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(array);
|
||||||
|
|
||||||
|
return array.ValuesCase switch
|
||||||
|
{
|
||||||
|
MxArray.ValuesOneofCase.BoolValues => array.BoolValues.Values.ToArray(),
|
||||||
|
MxArray.ValuesOneofCase.Int32Values => array.Int32Values.Values.ToArray(),
|
||||||
|
MxArray.ValuesOneofCase.Int64Values => array.Int64Values.Values.ToArray(),
|
||||||
|
MxArray.ValuesOneofCase.FloatValues => array.FloatValues.Values.ToArray(),
|
||||||
|
MxArray.ValuesOneofCase.DoubleValues => array.DoubleValues.Values.ToArray(),
|
||||||
|
MxArray.ValuesOneofCase.StringValues => array.StringValues.Values.ToArray(),
|
||||||
|
MxArray.ValuesOneofCase.TimestampValues => array.TimestampValues.Values
|
||||||
|
.Select(timestamp => timestamp.ToDateTimeOffset())
|
||||||
|
.ToArray(),
|
||||||
|
MxArray.ValuesOneofCase.RawValues => array.RawValues.Values
|
||||||
|
.Select(value => value.ToByteArray())
|
||||||
|
.ToArray(),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an MxValue with MxDataType.Unknown from raw byte data, variant type, and diagnostic info.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">Raw byte data representing the value.</param>
|
||||||
|
/// <param name="variantType">Variant type string (e.g., "VT_BSTR").</param>
|
||||||
|
/// <param name="rawDiagnostic">Diagnostic string describing the raw value.</param>
|
||||||
|
/// <param name="rawDataType">Optional MXAccess data type override.</param>
|
||||||
|
public static MxValue ToRawMxValue(
|
||||||
|
byte[] value,
|
||||||
|
string variantType,
|
||||||
|
string rawDiagnostic,
|
||||||
|
int rawDataType = 0)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
|
|
||||||
|
return new MxValue
|
||||||
|
{
|
||||||
|
DataType = MxDataType.Unknown,
|
||||||
|
VariantType = variantType,
|
||||||
|
RawDiagnostic = rawDiagnostic,
|
||||||
|
RawDataType = rawDataType,
|
||||||
|
RawValue = ByteString.CopyFrom(value),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxValue CreateArrayValue(
|
||||||
|
MxDataType dataType,
|
||||||
|
string variantType,
|
||||||
|
int length,
|
||||||
|
MxArray array)
|
||||||
|
{
|
||||||
|
array.Dimensions.Add((uint)length);
|
||||||
|
return new MxValue
|
||||||
|
{
|
||||||
|
DataType = dataType,
|
||||||
|
VariantType = variantType,
|
||||||
|
ArrayValue = array,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("MxGateway.Client.Tests")]
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps low-level <see cref="RpcException"/>s raised by the gRPC stack to the client's
|
||||||
|
/// native exception hierarchy. Shared by every gateway and Galaxy Repository transport
|
||||||
|
/// so the gRPC-to-native translation has exactly one implementation.
|
||||||
|
/// </summary>
|
||||||
|
internal static class RpcExceptionMapper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Translates a <see cref="RpcException"/> into the most specific native exception type.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="exception">The gRPC exception to translate.</param>
|
||||||
|
/// <param name="cancellationToken">
|
||||||
|
/// The cancellation token of the originating call; used to distinguish a caller-driven
|
||||||
|
/// cancellation from a server-side <see cref="StatusCode.Cancelled"/> status.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>
|
||||||
|
/// An <see cref="OperationCanceledException"/> when the call was cancelled, a typed
|
||||||
|
/// authentication/authorization exception for auth statuses, or an
|
||||||
|
/// <see cref="MxGatewayException"/> carrying the originating gRPC <see cref="StatusCode"/>.
|
||||||
|
/// </returns>
|
||||||
|
public static Exception Map(
|
||||||
|
RpcException exception,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(exception);
|
||||||
|
|
||||||
|
if (cancellationToken.IsCancellationRequested || exception.StatusCode == StatusCode.Cancelled)
|
||||||
|
{
|
||||||
|
return new OperationCanceledException(
|
||||||
|
exception.Status.Detail,
|
||||||
|
exception,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return exception.StatusCode switch
|
||||||
|
{
|
||||||
|
StatusCode.Unauthenticated => new MxGatewayAuthenticationException(
|
||||||
|
exception.Status.Detail,
|
||||||
|
statusCode: exception.StatusCode,
|
||||||
|
innerException: exception),
|
||||||
|
StatusCode.PermissionDenied => new MxGatewayAuthorizationException(
|
||||||
|
exception.Status.Detail,
|
||||||
|
statusCode: exception.StatusCode,
|
||||||
|
innerException: exception),
|
||||||
|
_ => new MxGatewayException(
|
||||||
|
exception.Status.Detail,
|
||||||
|
exception.StatusCode,
|
||||||
|
exception),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
# .NET Client Projects
|
||||||
|
|
||||||
|
The .NET client workspace contains the MXAccess Gateway client library, test
|
||||||
|
CLI, and unit tests.
|
||||||
|
|
||||||
|
## Projects
|
||||||
|
|
||||||
|
| Project | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `MxGateway.Client` | .NET 10 library entry point, raw gRPC calls, and session helpers. |
|
||||||
|
| `MxGateway.Client.Cli` | Test CLI for smoke and diagnostic commands. |
|
||||||
|
| `MxGateway.Client.Tests` | Unit tests for client options, generated contract wiring, auth metadata, session helpers, cancellation, and event streaming. |
|
||||||
|
|
||||||
|
The projects reference `src/MxGateway.Contracts/MxGateway.Contracts.csproj` so
|
||||||
|
the client compiles against the same generated protobuf and gRPC types as the
|
||||||
|
gateway. `clients/dotnet/generated` remains reserved for generator output if a
|
||||||
|
future client build switches to client-local `Grpc.Tools` generation.
|
||||||
|
|
||||||
|
## Build And Test
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet build clients/dotnet/MxGateway.Client.sln
|
||||||
|
dotnet test clients/dotnet/MxGateway.Client.sln --no-build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Packaging
|
||||||
|
|
||||||
|
Create local library and CLI artifacts from the repository root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$dotnetPackageOutput = Join-Path (Get-Location) 'artifacts/clients/dotnet'
|
||||||
|
dotnet pack clients/dotnet/MxGateway.Client/MxGateway.Client.csproj -c Release -p:PackageOutputPath="$dotnetPackageOutput"
|
||||||
|
dotnet publish clients/dotnet/MxGateway.Client.Cli/MxGateway.Client.Cli.csproj -c Release -o artifacts/clients/dotnet/mxgw-dotnet
|
||||||
|
```
|
||||||
|
|
||||||
|
The library package references the shared contracts project at build time. The
|
||||||
|
published CLI runs from `artifacts/clients/dotnet/mxgw-dotnet`.
|
||||||
|
|
||||||
|
## Regenerating Protobuf Bindings
|
||||||
|
|
||||||
|
The .NET client uses the generated C# types from
|
||||||
|
`src/MxGateway.Contracts/Generated`. Regenerate those files through the
|
||||||
|
contracts project:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet build src/MxGateway.Contracts/MxGateway.Contracts.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client Usage
|
||||||
|
|
||||||
|
`MxGatewayClient` opens a gRPC channel to the gateway and attaches the API key
|
||||||
|
to every unary and streaming call as `authorization: Bearer <api-key>`.
|
||||||
|
Cancellation tokens passed to the public methods flow to the generated gRPC
|
||||||
|
call. Client-side cancellation stops waiting for the gateway response; it does
|
||||||
|
not abort an MXAccess COM call that is already executing inside a worker.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await using MxGatewayClient client = MxGatewayClient.Create(
|
||||||
|
new MxGatewayClientOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost:5000"),
|
||||||
|
ApiKey = apiKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int serverHandle = await session.RegisterAsync("sample-client");
|
||||||
|
int itemHandle = await session.AddItemAsync(
|
||||||
|
serverHandle,
|
||||||
|
"Area001.Pump001.Speed");
|
||||||
|
|
||||||
|
await session.AdviseAsync(serverHandle, itemHandle);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await session.CloseAsync();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `OpenSessionRawAsync`, `CloseSessionRawAsync`, `InvokeAsync`, and
|
||||||
|
`StreamEventsAsync` when tests or parity tools need direct generated protobuf
|
||||||
|
messages. `MxGatewaySession.OpenSessionReply` keeps the raw session-open reply
|
||||||
|
available, and command helpers have `*RawAsync` variants when callers need the
|
||||||
|
complete `MxCommandReply`.
|
||||||
|
|
||||||
|
### Bulk Commands
|
||||||
|
|
||||||
|
The session exposes bulk variants for every command family that has one
|
||||||
|
upstream — they all carry a list of entries in one gRPC round-trip, the worker
|
||||||
|
runs the per-item MXAccess calls sequentially on its STA, and the reply
|
||||||
|
returns one result per requested entry. Per-entry failures populate
|
||||||
|
`WasSuccessful = false` with the underlying HRESULT and never throw; only
|
||||||
|
protocol-level failures throw via `EnsureProtocolSuccess`.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Subscribe + Unsubscribe to a batch of tags in one round-trip
|
||||||
|
IReadOnlyList<SubscribeResult> subResults = await session.SubscribeBulkAsync(
|
||||||
|
serverHandle,
|
||||||
|
new[] { "Area001.Pump001.Speed", "Area001.Pump001.RunHours" });
|
||||||
|
int[] itemHandles = subResults.Where(r => r.WasSuccessful).Select(r => r.ItemHandle).ToArray();
|
||||||
|
await session.UnsubscribeBulkAsync(serverHandle, itemHandles);
|
||||||
|
|
||||||
|
// Bulk Write — sequential MXAccess Write per entry.
|
||||||
|
IReadOnlyList<BulkWriteResult> writeResults = await session.WriteBulkAsync(
|
||||||
|
serverHandle,
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new WriteBulkEntry { ItemHandle = h1, UserId = 0, Value = 1.0.ToMxValue() },
|
||||||
|
new WriteBulkEntry { ItemHandle = h2, UserId = 0, Value = 2.0.ToMxValue() },
|
||||||
|
});
|
||||||
|
foreach (BulkWriteResult r in writeResults.Where(r => !r.WasSuccessful))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"item {r.ItemHandle}: {r.ErrorMessage}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk Read — returns the cached OnDataChange value when the tag is already
|
||||||
|
// advised (was_cached = true) or takes a one-shot snapshot otherwise.
|
||||||
|
IReadOnlyList<BulkReadResult> readResults = await session.ReadBulkAsync(
|
||||||
|
serverHandle,
|
||||||
|
new[] { "Area001.Pump001.Speed", "Area001.Pump002.Speed" },
|
||||||
|
timeout: TimeSpan.FromMilliseconds(750));
|
||||||
|
```
|
||||||
|
|
||||||
|
`Write2BulkAsync`, `WriteSecuredBulkAsync`, and `WriteSecured2BulkAsync` follow
|
||||||
|
the same shape; the secured variants additionally carry `CurrentUserId` and
|
||||||
|
`VerifierUserId` per entry and require `invoke:secure` scope.
|
||||||
|
|
||||||
|
`MxGatewaySession.CloseAsync` is explicit and idempotent. Repeated calls return
|
||||||
|
the first `CloseSessionReply` instead of sending another close request.
|
||||||
|
|
||||||
|
## Values, Status, And Errors
|
||||||
|
|
||||||
|
The client provides extension helpers for generated protobuf values. Use
|
||||||
|
`ToMxValue()` on .NET scalar values and typed arrays to create `MxValue`
|
||||||
|
instances for `Write` and `Write2`. Use `ToClrValue()` and
|
||||||
|
`GetProjectionKind()` when test or diagnostic code needs to inspect generated
|
||||||
|
`MxValue` replies while preserving `rawDiagnostic`, raw data type fields, and
|
||||||
|
raw byte payloads.
|
||||||
|
|
||||||
|
`MxStatusProxy.IsSuccess()` and `ToDiagnosticSummary()` expose MXAccess status
|
||||||
|
arrays without collapsing them into a single gateway success flag. Command
|
||||||
|
reply helpers follow the same split:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
reply.EnsureProtocolSuccess();
|
||||||
|
reply.EnsureMxAccessSuccess();
|
||||||
|
```
|
||||||
|
|
||||||
|
`EnsureProtocolSuccess()` raises gateway, session, worker, or command
|
||||||
|
exceptions for gateway-level failures. It leaves
|
||||||
|
`PROTOCOL_STATUS_CODE_MXACCESS_FAILURE` to `EnsureMxAccessSuccess()` so callers
|
||||||
|
can keep the full `MxCommandReply`, HRESULT, and status array when MXAccess
|
||||||
|
itself rejects a command. `MxAccessException.Reply` contains the raw generated
|
||||||
|
reply.
|
||||||
|
|
||||||
|
When a gRPC call itself fails, the transport maps the underlying
|
||||||
|
`RpcException` to a native exception: `Unauthenticated` becomes
|
||||||
|
`MxGatewayAuthenticationException`, `PermissionDenied` becomes
|
||||||
|
`MxGatewayAuthorizationException`, a cancelled call becomes
|
||||||
|
`OperationCanceledException`, and every other status becomes a base
|
||||||
|
`MxGatewayException`. `MxGatewayException.StatusCode` carries the originating
|
||||||
|
gRPC `Grpc.Core.StatusCode` (non-null whenever the failure came from a gRPC
|
||||||
|
status), so callers can distinguish a transient outage (`Unavailable`) from a
|
||||||
|
permanent error (`InvalidArgument`, `NotFound`) without downcasting
|
||||||
|
`InnerException`.
|
||||||
|
|
||||||
|
## CLI Usage
|
||||||
|
|
||||||
|
The test CLI supports deterministic JSON output for automation:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- version --json
|
||||||
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- open-session --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
|
||||||
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- register --session-id <id> --client-name mxgw-dotnet-cli --json
|
||||||
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- add-item --session-id <id> --server-handle 1 --item Area001.Pump001.Speed --json
|
||||||
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- advise --session-id <id> --server-handle 1 --item-handle 1 --json
|
||||||
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- write --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --json
|
||||||
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- write2 --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --timestamp 2026-01-01T00:00:00Z --json
|
||||||
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- stream-events --session-id <id> --max-events 1 --json
|
||||||
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
|
||||||
|
```
|
||||||
|
|
||||||
|
`smoke` opens a session, registers a client, adds one item, advises it,
|
||||||
|
optionally writes a value when `--type` and `--value` are supplied, reads a
|
||||||
|
bounded event stream, and closes the session in a `finally` block. CLI error
|
||||||
|
output redacts the effective API key, whether it was supplied through
|
||||||
|
`--api-key` or resolved from the `--api-key-env` environment variable.
|
||||||
|
|
||||||
|
## Galaxy Repository Browse
|
||||||
|
|
||||||
|
`GalaxyRepositoryClient` is a separate read-only wrapper around the
|
||||||
|
`GalaxyRepository` gRPC service exposed by the same gateway. It shares the API
|
||||||
|
key auth interceptor with `MxGatewayClient` and requires the `metadata:read`
|
||||||
|
scope server-side. Use it to probe the ZB SQL connection, watch
|
||||||
|
`time_of_last_deploy` for redeployments, and enumerate the deployed Galaxy
|
||||||
|
object hierarchy plus each object's dynamic attributes.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await using GalaxyRepositoryClient repository = GalaxyRepositoryClient.Create(
|
||||||
|
new MxGatewayClientOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost:5000"),
|
||||||
|
ApiKey = apiKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool ok = await repository.TestConnectionAsync();
|
||||||
|
DateTime? lastDeploy = await repository.GetLastDeployTimeAsync();
|
||||||
|
|
||||||
|
IReadOnlyList<GalaxyObject> objects = await repository.DiscoverHierarchyAsync();
|
||||||
|
foreach (GalaxyObject galaxyObject in objects)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"{galaxyObject.TagName} ({galaxyObject.ContainedName})");
|
||||||
|
foreach (GalaxyAttribute attribute in galaxyObject.Attributes)
|
||||||
|
{
|
||||||
|
Console.WriteLine($" {attribute.AttributeName} -> {attribute.FullTagReference}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `DiscoverHierarchyOptions` to request a server-side slice without pulling
|
||||||
|
the full Galaxy:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
IReadOnlyList<GalaxyObject> pumps = await repository.DiscoverHierarchyAsync(
|
||||||
|
new DiscoverHierarchyOptions
|
||||||
|
{
|
||||||
|
RootContainedPath = "Area1/Line3",
|
||||||
|
TagNameGlob = "Pump_*",
|
||||||
|
IncludeAttributes = false,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The CLI exposes the same operations:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-test-connection --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
|
||||||
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-last-deploy --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
|
||||||
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-discover --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
### Watching deploy events
|
||||||
|
|
||||||
|
`WatchDeployEventsAsync` opens the `WatchDeployEvents` server-streaming RPC. The
|
||||||
|
server emits a bootstrap event with the current state on subscribe, then one
|
||||||
|
event per new `time_of_last_deploy`. Pass a `lastSeenDeployTime` to suppress the
|
||||||
|
bootstrap when the caller already holds the current deploy time. Use the
|
||||||
|
monotonic `Sequence` field to detect dropped events: gaps mean the
|
||||||
|
per-subscriber server-side buffer overflowed and the caller should reconcile.
|
||||||
|
|
||||||
|
Streaming RPCs are not wrapped by the unary safe-read retry pipeline. The
|
||||||
|
caller is responsible for reopening the stream on transient failures.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await using GalaxyRepositoryClient repository = GalaxyRepositoryClient.Create(options);
|
||||||
|
|
||||||
|
DateTimeOffset? lastSeen = null;
|
||||||
|
await foreach (DeployEvent evt in repository.WatchDeployEventsAsync(
|
||||||
|
lastSeen,
|
||||||
|
cancellationToken))
|
||||||
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
$"seq={evt.Sequence} objects={evt.ObjectCount} attributes={evt.AttributeCount}");
|
||||||
|
if (evt.TimeOfLastDeployPresent && evt.TimeOfLastDeploy is not null)
|
||||||
|
{
|
||||||
|
lastSeen = evt.TimeOfLastDeploy.ToDateTimeOffset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The CLI counterpart streams events until Ctrl+C (or `--max-events`):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
|
||||||
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --last-seen-deploy-time 2026-04-28T14:30:00Z --json
|
||||||
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --max-events 5 --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Use TLS options for a secured gateway:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- smoke --endpoint https://mxgateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration Checks
|
||||||
|
|
||||||
|
Run live checks only when a gateway and MXAccess-backed worker are available:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:MXGATEWAY_INTEGRATION = '1'
|
||||||
|
$env:MXGATEWAY_ENDPOINT = 'http://localhost:5000'
|
||||||
|
$env:MXGATEWAY_API_KEY = '<gateway-api-key>'
|
||||||
|
$env:MXGATEWAY_TEST_ITEM = 'Area001.Pump001.Speed'
|
||||||
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- smoke --endpoint $env:MXGATEWAY_ENDPOINT --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||||
|
- [Client Proto Generation](../../docs/ClientProtoGeneration.md)
|
||||||
|
- [.NET Client Detailed Design](./DotnetClientDesign.md)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -6,8 +6,8 @@ Provide an idiomatic Go client module for MXAccess Gateway, plus a test CLI and
|
|||||||
unit tests. The Go client should be suitable for services and command-line
|
unit tests. The Go client should be suitable for services and command-line
|
||||||
automation.
|
automation.
|
||||||
|
|
||||||
Follow the [Go Style Guide](./style-guides/GoStyleGuide.md) for handwritten
|
Follow the [Go Style Guide](../../docs/style-guides/GoStyleGuide.md) for handwritten
|
||||||
code and the [Protobuf Style Guide](./style-guides/ProtobufStyleGuide.md) for
|
code and the [Protobuf Style Guide](../../docs/style-guides/ProtobufStyleGuide.md) for
|
||||||
generated contract inputs.
|
generated contract inputs.
|
||||||
|
|
||||||
## Module Layout
|
## Module Layout
|
||||||
@@ -74,6 +74,12 @@ func (s *Session) Unregister(ctx context.Context, serverHandle int32) error
|
|||||||
func (s *Session) AddItem(ctx context.Context, serverHandle int32, item string) (int32, error)
|
func (s *Session) AddItem(ctx context.Context, serverHandle int32, item string) (int32, error)
|
||||||
func (s *Session) AddItem2(ctx context.Context, serverHandle int32, item, context string) (int32, error)
|
func (s *Session) AddItem2(ctx context.Context, serverHandle int32, item, context string) (int32, error)
|
||||||
func (s *Session) Advise(ctx context.Context, serverHandle, itemHandle int32) error
|
func (s *Session) Advise(ctx context.Context, serverHandle, itemHandle int32) error
|
||||||
|
func (s *Session) AddItemBulk(ctx context.Context, serverHandle int32, tagAddresses []string) ([]*pb.SubscribeResult, error)
|
||||||
|
func (s *Session) AdviseItemBulk(ctx context.Context, serverHandle int32, itemHandles []int32) ([]*pb.SubscribeResult, error)
|
||||||
|
func (s *Session) RemoveItemBulk(ctx context.Context, serverHandle int32, itemHandles []int32) ([]*pb.SubscribeResult, error)
|
||||||
|
func (s *Session) UnAdviseItemBulk(ctx context.Context, serverHandle int32, itemHandles []int32) ([]*pb.SubscribeResult, error)
|
||||||
|
func (s *Session) SubscribeBulk(ctx context.Context, serverHandle int32, tagAddresses []string) ([]*pb.SubscribeResult, error)
|
||||||
|
func (s *Session) UnsubscribeBulk(ctx context.Context, serverHandle int32, itemHandles []int32) ([]*pb.SubscribeResult, error)
|
||||||
func (s *Session) Write(ctx context.Context, serverHandle, itemHandle int32, value Value, userID int32) error
|
func (s *Session) Write(ctx context.Context, serverHandle, itemHandle int32, value Value, userID int32) error
|
||||||
func (s *Session) Events(ctx context.Context) (<-chan EventResult, error)
|
func (s *Session) Events(ctx context.Context) (<-chan EventResult, error)
|
||||||
func (s *Session) Close(ctx context.Context) error
|
func (s *Session) Close(ctx context.Context) error
|
||||||
@@ -170,3 +176,10 @@ MXGATEWAY_INTEGRATION=1
|
|||||||
|
|
||||||
Integration test should run `OpenSession`, `Register`, `AddItem`, `Advise`,
|
Integration test should run `OpenSession`, `Register`, `AddItem`, `Advise`,
|
||||||
bounded `StreamEvents`, and `CloseSession`.
|
bounded `StreamEvents`, and `CloseSession`.
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Client Libraries Detailed Design](../../docs/ClientLibrariesDesign.md)
|
||||||
|
- [Client Proto Generation](../../docs/ClientProtoGeneration.md)
|
||||||
|
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||||
|
- [Go Style Guide](../../docs/style-guides/GoStyleGuide.md)
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
# Go Client
|
||||||
|
|
||||||
|
The Go client module contains the generated MXAccess Gateway protobuf bindings,
|
||||||
|
a small handwritten `mxgateway` package, and the `mxgw-go` test CLI scaffold.
|
||||||
|
The module uses the shared proto inputs documented in
|
||||||
|
`../../docs/ClientProtoGeneration.md` so gateway and client contracts stay in
|
||||||
|
sync.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
clients/go/
|
||||||
|
go.mod
|
||||||
|
generate-proto.ps1
|
||||||
|
internal/generated/
|
||||||
|
mxgateway/
|
||||||
|
cmd/mxgw-go/
|
||||||
|
```
|
||||||
|
|
||||||
|
`internal/generated` contains code produced by `protoc`, `protoc-gen-go`, and
|
||||||
|
`protoc-gen-go-grpc`. Do not edit generated files by hand.
|
||||||
|
|
||||||
|
## Regenerating Protobuf Bindings
|
||||||
|
|
||||||
|
Run generation after the shared `.proto` files or the Go output path changes:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./generate-proto.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
The script uses the tool paths recorded in `../../docs/ToolchainLinks.md`.
|
||||||
|
|
||||||
|
## Build And Test
|
||||||
|
|
||||||
|
Run the Go module checks from `clients/go`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go test ./...
|
||||||
|
go build ./...
|
||||||
|
go vet ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
The tests parse the shared JSON fixtures, exercise value and status conversion,
|
||||||
|
use `bufconn` for fake gateway auth and streaming behavior, and cover CLI JSON
|
||||||
|
redaction.
|
||||||
|
|
||||||
|
## Packaging
|
||||||
|
|
||||||
|
Build a local CLI executable from `clients/go`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
New-Item -ItemType Directory -Force ../../artifacts/clients/go | Out-Null
|
||||||
|
go build -o ../../artifacts/clients/go/mxgw-go.exe ./cmd/mxgw-go
|
||||||
|
```
|
||||||
|
|
||||||
|
Install the CLI into the active `GOBIN` or `GOPATH/bin`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go install ./cmd/mxgw-go
|
||||||
|
```
|
||||||
|
|
||||||
|
Other Go modules can consume the library package with the module path
|
||||||
|
`gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway`.
|
||||||
|
|
||||||
|
## Client API
|
||||||
|
|
||||||
|
Use `mxgateway.Dial` with `mxgateway.Options` to configure plaintext or TLS
|
||||||
|
transport, API-key metadata, dial timeout, and per-call timeout:
|
||||||
|
|
||||||
|
```go
|
||||||
|
client, err := mxgateway.Dial(ctx, mxgateway.Options{
|
||||||
|
Endpoint: "localhost:5000",
|
||||||
|
APIKey: os.Getenv("MXGATEWAY_API_KEY"),
|
||||||
|
Plaintext: true,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`Client.OpenSession` returns a `Session` with helpers for `Register`,
|
||||||
|
`AddItem`, `AddItem2`, `Advise`, `Write`, the full bulk family
|
||||||
|
(`AddItemBulk`, `AdviseItemBulk`, `RemoveItemBulk`, `UnAdviseItemBulk`,
|
||||||
|
`SubscribeBulk`, `UnsubscribeBulk`, `WriteBulk`, `Write2Bulk`,
|
||||||
|
`WriteSecuredBulk`, `WriteSecured2Bulk`, `ReadBulk`), `Events`, and
|
||||||
|
`Close`. Bulk variants carry a list of entries in one round-trip and
|
||||||
|
return one result per entry; per-entry MXAccess failures appear as
|
||||||
|
`was_successful = false` and never return as Go errors. `ReadBulk` accepts
|
||||||
|
a `time.Duration` per-tag timeout and returns cached `OnDataChange`
|
||||||
|
values when the tag is already advised (`WasCached = true`) without
|
||||||
|
touching the existing subscription. Prefer
|
||||||
|
`SubscribeEvents` or `SubscribeEventsAfter` for long-running streams because the
|
||||||
|
returned subscription owns cancellation and exposes `Close` for deterministic
|
||||||
|
goroutine cleanup. `Events` and `EventsAfter` are a compatibility shim with a
|
||||||
|
bounded internal buffer: if the consumer drains too slowly the buffer fills,
|
||||||
|
the underlying stream is cancelled, and a terminal `EventResult` carrying
|
||||||
|
`ErrEventBufferOverflow` is delivered as the channel's last item before it
|
||||||
|
closes — so a slow consumer can distinguish dropped events from a normal
|
||||||
|
end-of-stream. `SubscribeEvents` blocks instead of dropping, so use it when no
|
||||||
|
events may be lost. Raw protobuf messages remain available through the
|
||||||
|
`mxgateway` package aliases and the `Raw` helper methods. Typed errors support
|
||||||
|
`errors.As` for `GatewayError`, `CommandError`, and `MxAccessError`; command
|
||||||
|
errors preserve the raw reply.
|
||||||
|
|
||||||
|
`Dial` and `DialGalaxy` create the connection lazily (`grpc.NewClient`): a
|
||||||
|
gateway that is briefly unavailable no longer turns into a hard error — the
|
||||||
|
connection recovers once the gateway comes up. To keep fail-fast behavior,
|
||||||
|
both run a readiness probe bounded by `DialTimeout` (default 10s, or the
|
||||||
|
context deadline when sooner) and return a `*GatewayError` if the gateway
|
||||||
|
cannot be reached in that window.
|
||||||
|
|
||||||
|
For retry, timeout, and auth handling, `GatewayError.Code()` exposes the
|
||||||
|
wrapped gRPC `codes.Code`, and `mxgateway.IsTransient(err)` reports whether a
|
||||||
|
failure (`Unavailable`, `DeadlineExceeded`, `ResourceExhausted`, `Aborted`)
|
||||||
|
may succeed on retry — so callers do not have to unwrap the error and call
|
||||||
|
`status.Code` themselves.
|
||||||
|
|
||||||
|
## Galaxy Repository browse
|
||||||
|
|
||||||
|
The `GalaxyRepository` service (proto package `galaxy_repository.v1`) is a
|
||||||
|
read-only metadata-only browse over the AVEVA System Platform Galaxy
|
||||||
|
Repository. It uses the same API-key authentication as the MXAccess Gateway
|
||||||
|
and requires the `metadata:read` scope. Use `mxgateway.DialGalaxy` to obtain a
|
||||||
|
`*GalaxyClient` that mirrors the connection-management conventions of
|
||||||
|
`Client`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
galaxy, err := mxgateway.DialGalaxy(ctx, mxgateway.Options{
|
||||||
|
Endpoint: "localhost:5000",
|
||||||
|
APIKey: os.Getenv("MXGATEWAY_API_KEY"),
|
||||||
|
Plaintext: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer galaxy.Close()
|
||||||
|
|
||||||
|
ok, err := galaxy.TestConnection(ctx)
|
||||||
|
deployTime, present, err := galaxy.GetLastDeployTime(ctx)
|
||||||
|
objects, err := galaxy.DiscoverHierarchy(ctx)
|
||||||
|
```
|
||||||
|
|
||||||
|
`GetLastDeployTime` returns `(time.Time{}, false, nil)` when the server
|
||||||
|
reports `present=false` (no deploy recorded). `DiscoverHierarchy` returns
|
||||||
|
the generated `*GalaxyObject` slice with each object's dynamic attributes
|
||||||
|
populated for direct contract access.
|
||||||
|
|
||||||
|
### Watching deploy events
|
||||||
|
|
||||||
|
`WatchDeployEvents` opens a server-streaming subscription. The server emits a
|
||||||
|
bootstrap event with the current Galaxy state immediately on subscribe, then
|
||||||
|
one `DeployEvent` per new deploy. `Sequence` is monotonic per server start;
|
||||||
|
gaps signal dropped events. Pass a non-nil `lastSeenDeployTime` to suppress the
|
||||||
|
bootstrap event when resuming from a known checkpoint:
|
||||||
|
|
||||||
|
```go
|
||||||
|
streamCtx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
events, errs, err := galaxy.WatchDeployEvents(streamCtx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case ev, ok := <-events:
|
||||||
|
if !ok {
|
||||||
|
return nil // stream completed (server EOF or ctx cancelled)
|
||||||
|
}
|
||||||
|
log.Printf("seq=%d objects=%d attrs=%d",
|
||||||
|
ev.GetSequence(), ev.GetObjectCount(), ev.GetAttributeCount())
|
||||||
|
case streamErr := <-errs:
|
||||||
|
if streamErr != nil {
|
||||||
|
return streamErr // *GatewayError
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Cancel the supplied context to tear down the stream cleanly. Both channels
|
||||||
|
close after EOF, cancellation, or a terminal error; surfaced errors are wrapped
|
||||||
|
in `*GatewayError`.
|
||||||
|
|
||||||
|
The CLI exposes the same RPC via `galaxy-watch`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go run ./cmd/mxgw-go galaxy-watch -plaintext
|
||||||
|
go run ./cmd/mxgw-go galaxy-watch -plaintext -json
|
||||||
|
go run ./cmd/mxgw-go galaxy-watch -plaintext -last-seen-deploy-time 2026-04-28T10:00:00Z # whole-second RFC 3339
|
||||||
|
go run ./cmd/mxgw-go galaxy-watch -plaintext -last-seen-deploy-time 2026-04-28T10:00:00.123Z # fractional seconds also accepted
|
||||||
|
go run ./cmd/mxgw-go galaxy-watch -plaintext -limit 5
|
||||||
|
```
|
||||||
|
|
||||||
|
The command runs until Ctrl+C (or the optional `-limit` is reached) and prints
|
||||||
|
one line per event in text mode or one JSON object per event with `-json`.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
The `mxgw-go` CLI emits JSON with redacted API keys for commands that connect to
|
||||||
|
the gateway:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go run ./cmd/mxgw-go version -json
|
||||||
|
go run ./cmd/mxgw-go open-session -endpoint localhost:5000 -plaintext -json
|
||||||
|
go run ./cmd/mxgw-go register -session-id <id> -client-name mxgw-go -plaintext -json
|
||||||
|
go run ./cmd/mxgw-go add-item -session-id <id> -server-handle 1 -item Area001.Tag.Value -plaintext -json
|
||||||
|
go run ./cmd/mxgw-go advise -session-id <id> -server-handle 1 -item-handle 1 -plaintext -json
|
||||||
|
go run ./cmd/mxgw-go write -session-id <id> -server-handle 1 -item-handle 1 -type int32 -value 123 -plaintext -json
|
||||||
|
go run ./cmd/mxgw-go stream-events -session-id <id> -plaintext -json
|
||||||
|
go run ./cmd/mxgw-go smoke -item Area001.Tag.Value -plaintext -json
|
||||||
|
go run ./cmd/mxgw-go galaxy-test-connection -plaintext -json
|
||||||
|
go run ./cmd/mxgw-go galaxy-last-deploy -plaintext -json
|
||||||
|
go run ./cmd/mxgw-go galaxy-discover -plaintext -json
|
||||||
|
go run ./cmd/mxgw-go galaxy-watch -plaintext -json
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `-api-key-env MXGATEWAY_API_KEY` or `-api-key <key>` when authentication is
|
||||||
|
enabled. CLI output redacts the key value and never writes the raw secret.
|
||||||
|
|
||||||
|
Use TLS options for a secured gateway:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go run ./cmd/mxgw-go smoke -endpoint mxgateway.example.local:5001 -ca-cert C:\certs\mxgateway-ca.pem -server-name-override mxgateway.example.local -api-key-env MXGATEWAY_API_KEY -item Area001.Tag.Value -json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration Checks
|
||||||
|
|
||||||
|
Run live checks only when a gateway and MXAccess-backed worker are available:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:MXGATEWAY_INTEGRATION = '1'
|
||||||
|
$env:MXGATEWAY_ENDPOINT = 'localhost:5000'
|
||||||
|
$env:MXGATEWAY_API_KEY = '<gateway-api-key>'
|
||||||
|
$env:MXGATEWAY_TEST_ITEM = 'Area001.Tag.Value'
|
||||||
|
go run ./cmd/mxgw-go smoke -endpoint $env:MXGATEWAY_ENDPOINT -plaintext -api-key-env MXGATEWAY_API_KEY -item $env:MXGATEWAY_TEST_ITEM -json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||||
|
- [Client Proto Generation](../../docs/ClientProtoGeneration.md)
|
||||||
|
- [Go Client Detailed Design](./GoClientDesign.md)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,251 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunVersionJSON(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
|
if err := runWithIO(t.Context(), []string{"version", "-json"}, &stdout, &stderr); err != nil {
|
||||||
|
t.Fatalf("runWithIO() error = %v; stderr = %s", err, stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var output versionOutput
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &output); err != nil {
|
||||||
|
t.Fatalf("parse JSON: %v", err)
|
||||||
|
}
|
||||||
|
if output.GatewayProtocolVersion == 0 || output.WorkerProtocolVersion == 0 {
|
||||||
|
t.Fatalf("protocol versions were not populated: %+v", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommonOptionsRedactsAPIKey(t *testing.T) {
|
||||||
|
options, err := (&commonOptions{
|
||||||
|
Endpoint: "localhost:5000",
|
||||||
|
APIKey: "mxgw_super_secret",
|
||||||
|
Plaintext: true,
|
||||||
|
CallTimeout: "2s",
|
||||||
|
}).resolved()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolved() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal options: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), "super_secret") {
|
||||||
|
t.Fatalf("redacted JSON leaked API key: %s", data)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "mxgw") {
|
||||||
|
t.Fatalf("redacted JSON did not preserve key shape: %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseValueBuildsTypedValue(t *testing.T) {
|
||||||
|
value, err := parseValue("int32", "123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseValue() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := value.GetInt32Value(); got != 123 {
|
||||||
|
t.Fatalf("int32 value = %d, want 123", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInt32ListParsesValidTokens(t *testing.T) {
|
||||||
|
items, err := parseInt32List("1, 2 ,3")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseInt32List() error = %v", err)
|
||||||
|
}
|
||||||
|
want := []int32{1, 2, 3}
|
||||||
|
if len(items) != len(want) {
|
||||||
|
t.Fatalf("parseInt32List() = %v, want %v", items, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if items[i] != want[i] {
|
||||||
|
t.Fatalf("parseInt32List()[%d] = %d, want %d", i, items[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInt32ListReturnsErrorOnMalformedToken(t *testing.T) {
|
||||||
|
items, err := parseInt32List("1,foo")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("parseInt32List() error = nil, want a parse error; items = %v", items)
|
||||||
|
}
|
||||||
|
if items != nil {
|
||||||
|
t.Fatalf("parseInt32List() items = %v, want nil on error", items)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "foo") {
|
||||||
|
t.Fatalf("parseInt32List() error = %q, want it to name the bad token", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseValueWrapsStrconvErrorWithFlagContext pins Client.Go-017: each
|
||||||
|
// typed branch of parseValue wraps the bare strconv error with `%w` and names
|
||||||
|
// the offending flag and value, so the CLI surface is consistent with
|
||||||
|
// parseInt32List ("invalid item handle %q: %w") and parseRfc3339Timestamp
|
||||||
|
// ("invalid RFC 3339 timestamp %q: %w").
|
||||||
|
func TestParseValueWrapsStrconvErrorWithFlagContext(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
valueType string
|
||||||
|
valueText string
|
||||||
|
}{
|
||||||
|
{"bool", "notabool"},
|
||||||
|
{"int32", "foo"},
|
||||||
|
{"int64", "foo"},
|
||||||
|
{"float", "notafloat"},
|
||||||
|
{"double", "notadouble"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.valueType, func(t *testing.T) {
|
||||||
|
_, err := parseValue(tc.valueType, tc.valueText)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("parseValue(%q, %q) error = nil, want a parse error", tc.valueType, tc.valueText)
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
if !strings.Contains(msg, "-value") {
|
||||||
|
t.Fatalf("parseValue() error = %q, want it to name the -value flag", msg)
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg, tc.valueType) {
|
||||||
|
t.Fatalf("parseValue() error = %q, want it to name the type %q", msg, tc.valueType)
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg, tc.valueText) {
|
||||||
|
t.Fatalf("parseValue() error = %q, want it to name the bad token %q", msg, tc.valueText)
|
||||||
|
}
|
||||||
|
// errors.Unwrap must reach the underlying strconv error so callers
|
||||||
|
// can still errors.Is/As against strconv.ErrSyntax if they care.
|
||||||
|
if errors.Unwrap(err) == nil {
|
||||||
|
t.Fatalf("parseValue() returned unwrapped error %q, want a %%w wrap", msg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunWriteBulkVariantGatesSecuredFlags pins the Client.Go-015 fix at the
|
||||||
|
// CLI surface: secured-only flags (-current-user-id, -verifier-user-id) must
|
||||||
|
// not be registered on the non-secured variants, and -user-id must not be
|
||||||
|
// registered on the secured variants. The flag package rejects an unknown
|
||||||
|
// flag with "flag provided but not defined", which a future refactor that
|
||||||
|
// re-broadens flag registration would silently undo without this test.
|
||||||
|
func TestRunWriteBulkVariantGatesSecuredFlags(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
command string
|
||||||
|
flag string
|
||||||
|
}{
|
||||||
|
{"write-bulk rejects -current-user-id", "write-bulk", "-current-user-id"},
|
||||||
|
{"write-bulk rejects -verifier-user-id", "write-bulk", "-verifier-user-id"},
|
||||||
|
{"write2-bulk rejects -current-user-id", "write2-bulk", "-current-user-id"},
|
||||||
|
{"write-secured-bulk rejects -user-id", "write-secured-bulk", "-user-id"},
|
||||||
|
{"write-secured2-bulk rejects -user-id", "write-secured2-bulk", "-user-id"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
err := runWithIO(t.Context(), []string{
|
||||||
|
tc.command,
|
||||||
|
"-plaintext",
|
||||||
|
"-session-id", "sess",
|
||||||
|
"-server-handle", "1",
|
||||||
|
"-item-handles", "1",
|
||||||
|
"-values", "1",
|
||||||
|
tc.flag, "1",
|
||||||
|
}, &stdout, &stderr)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("runWithIO(%s %s) error = nil, want flag-not-defined", tc.command, tc.flag)
|
||||||
|
}
|
||||||
|
combined := err.Error() + stderr.String()
|
||||||
|
if !strings.Contains(combined, "flag provided but not defined") {
|
||||||
|
t.Fatalf("runWithIO(%s %s) error/stderr = %q, want 'flag provided but not defined'", tc.command, tc.flag, combined)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunReadBulkRejectsMissingArgs pins the "session-id and items are
|
||||||
|
// required" validation in runReadBulk before any network dial happens.
|
||||||
|
func TestRunReadBulkRejectsMissingArgs(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
}{
|
||||||
|
{"no flags", []string{"read-bulk"}},
|
||||||
|
{"missing items", []string{"read-bulk", "-plaintext", "-session-id", "sess"}},
|
||||||
|
{"missing session-id", []string{"read-bulk", "-plaintext", "-items", "Tag.Attr"}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
err := runWithIO(t.Context(), tc.args, &stdout, &stderr)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("runWithIO(%v) error = nil, want validation error", tc.args)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "session-id and items are required") {
|
||||||
|
t.Fatalf("runWithIO(%v) error = %q, want 'session-id and items are required'", tc.args, err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunBenchReadBulkRejectsNonPositiveBulkSize pins the bulk-size>=1 check
|
||||||
|
// at runBenchReadBulk's flag-parsing stage so a future refactor cannot drop
|
||||||
|
// the positivity guard without breaking this test.
|
||||||
|
func TestRunBenchReadBulkRejectsNonPositiveBulkSize(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
err := runWithIO(t.Context(), []string{
|
||||||
|
"bench-read-bulk",
|
||||||
|
"-plaintext",
|
||||||
|
"-bulk-size", "0",
|
||||||
|
}, &stdout, &stderr)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("runWithIO(bench-read-bulk -bulk-size 0) error = nil, want positivity error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "bulk-size must be positive") {
|
||||||
|
t.Fatalf("runWithIO error = %q, want 'bulk-size must be positive'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunBenchReadBulkRejectsNonPositiveDuration pins the duration-seconds>=1
|
||||||
|
// check at runBenchReadBulk's flag-parsing stage.
|
||||||
|
func TestRunBenchReadBulkRejectsNonPositiveDuration(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
err := runWithIO(t.Context(), []string{
|
||||||
|
"bench-read-bulk",
|
||||||
|
"-plaintext",
|
||||||
|
"-duration-seconds", "0",
|
||||||
|
}, &stdout, &stderr)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("runWithIO(bench-read-bulk -duration-seconds 0) error = nil, want positivity error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "duration-seconds must be positive") {
|
||||||
|
t.Fatalf("runWithIO error = %q, want 'duration-seconds must be positive'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunWriteBulkVariantRejectsMismatchedHandlesAndValues pins the explicit
|
||||||
|
// "item-handles count ... does not match values count ..." check at the CLI
|
||||||
|
// surface so the validation error surfaces before any dial happens.
|
||||||
|
func TestRunWriteBulkVariantRejectsMismatchedHandlesAndValues(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
err := runWithIO(t.Context(), []string{
|
||||||
|
"write-bulk",
|
||||||
|
"-plaintext",
|
||||||
|
"-session-id", "sess",
|
||||||
|
"-server-handle", "1",
|
||||||
|
"-item-handles", "1,2,3",
|
||||||
|
"-values", "10,20",
|
||||||
|
}, &stdout, &stderr)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("runWithIO(write-bulk mismatched counts) error = nil, want mismatch error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "item-handles count") || !strings.Contains(err.Error(), "values count") {
|
||||||
|
t.Fatalf("runWithIO error = %q, want 'item-handles count ... values count ...'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..')
|
||||||
|
$protoRoot = Join-Path $repoRoot 'src\MxGateway.Contracts\Protos'
|
||||||
|
$outputRoot = Join-Path $PSScriptRoot 'internal\generated'
|
||||||
|
$modulePath = 'gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated'
|
||||||
|
$protoc = 'C:\Users\dohertj2\AppData\Local\Microsoft\WinGet\Packages\Google.Protobuf_Microsoft.Winget.Source_8wekyb3d8bbwe\bin\protoc.exe'
|
||||||
|
$goPluginPath = 'C:\Users\dohertj2\go\bin'
|
||||||
|
|
||||||
|
if (-not (Test-Path $protoc)) {
|
||||||
|
throw "protoc was not found at $protoc. See docs/ToolchainLinks.md."
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($pluginName in @('protoc-gen-go.exe', 'protoc-gen-go-grpc.exe')) {
|
||||||
|
$pluginPath = Join-Path $goPluginPath $pluginName
|
||||||
|
if (-not (Test-Path $pluginPath)) {
|
||||||
|
throw "$pluginName was not found at $pluginPath. See docs/ToolchainLinks.md."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Path $outputRoot -Force | Out-Null
|
||||||
|
Get-ChildItem -Path $outputRoot -Filter '*.pb.go' -File | Remove-Item
|
||||||
|
|
||||||
|
$env:Path = "$goPluginPath;$env:Path"
|
||||||
|
|
||||||
|
& $protoc `
|
||||||
|
--proto_path=$protoRoot `
|
||||||
|
--go_out=$outputRoot `
|
||||||
|
--go_opt=paths=source_relative `
|
||||||
|
"--go_opt=Mmxaccess_gateway.proto=$modulePath;generated" `
|
||||||
|
"--go_opt=Mmxaccess_worker.proto=$modulePath;generated" `
|
||||||
|
"--go_opt=Mgalaxy_repository.proto=$modulePath;generated" `
|
||||||
|
mxaccess_gateway.proto `
|
||||||
|
mxaccess_worker.proto `
|
||||||
|
galaxy_repository.proto
|
||||||
|
|
||||||
|
& $protoc `
|
||||||
|
--proto_path=$protoRoot `
|
||||||
|
--go-grpc_out=$outputRoot `
|
||||||
|
--go-grpc_opt=paths=source_relative `
|
||||||
|
"--go-grpc_opt=Mmxaccess_gateway.proto=$modulePath;generated" `
|
||||||
|
"--go-grpc_opt=Mgalaxy_repository.proto=$modulePath;generated" `
|
||||||
|
mxaccess_gateway.proto `
|
||||||
|
galaxy_repository.proto
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
module gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go
|
||||||
|
|
||||||
|
go 1.26
|
||||||
|
|
||||||
|
require (
|
||||||
|
google.golang.org/grpc v1.80.0
|
||||||
|
google.golang.org/protobuf v1.36.11
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
golang.org/x/net v0.49.0 // indirect
|
||||||
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
|
golang.org/x/text v0.33.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||||
|
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||||
|
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||||
|
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||||
|
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||||
|
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||||
|
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
|
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||||
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||||
|
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||||
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
|
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||||
|
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,984 @@
|
|||||||
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// protoc-gen-go v1.36.11
|
||||||
|
// protoc v7.34.1
|
||||||
|
// source: galaxy_repository.proto
|
||||||
|
|
||||||
|
package generated
|
||||||
|
|
||||||
|
import (
|
||||||
|
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||||
|
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||||
|
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
wrapperspb "google.golang.org/protobuf/types/known/wrapperspb"
|
||||||
|
reflect "reflect"
|
||||||
|
sync "sync"
|
||||||
|
unsafe "unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Verify that this generated code is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||||
|
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||||
|
)
|
||||||
|
|
||||||
|
type TestConnectionRequest struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TestConnectionRequest) Reset() {
|
||||||
|
*x = TestConnectionRequest{}
|
||||||
|
mi := &file_galaxy_repository_proto_msgTypes[0]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TestConnectionRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*TestConnectionRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *TestConnectionRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_galaxy_repository_proto_msgTypes[0]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use TestConnectionRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*TestConnectionRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_galaxy_repository_proto_rawDescGZIP(), []int{0}
|
||||||
|
}
|
||||||
|
|
||||||
|
type TestConnectionReply struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TestConnectionReply) Reset() {
|
||||||
|
*x = TestConnectionReply{}
|
||||||
|
mi := &file_galaxy_repository_proto_msgTypes[1]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TestConnectionReply) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*TestConnectionReply) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *TestConnectionReply) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_galaxy_repository_proto_msgTypes[1]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use TestConnectionReply.ProtoReflect.Descriptor instead.
|
||||||
|
func (*TestConnectionReply) Descriptor() ([]byte, []int) {
|
||||||
|
return file_galaxy_repository_proto_rawDescGZIP(), []int{1}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TestConnectionReply) GetOk() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.Ok
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetLastDeployTimeRequest struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetLastDeployTimeRequest) Reset() {
|
||||||
|
*x = GetLastDeployTimeRequest{}
|
||||||
|
mi := &file_galaxy_repository_proto_msgTypes[2]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetLastDeployTimeRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*GetLastDeployTimeRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *GetLastDeployTimeRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_galaxy_repository_proto_msgTypes[2]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use GetLastDeployTimeRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*GetLastDeployTimeRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_galaxy_repository_proto_rawDescGZIP(), []int{2}
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetLastDeployTimeReply struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Present bool `protobuf:"varint,1,opt,name=present,proto3" json:"present,omitempty"`
|
||||||
|
TimeOfLastDeploy *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=time_of_last_deploy,json=timeOfLastDeploy,proto3" json:"time_of_last_deploy,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetLastDeployTimeReply) Reset() {
|
||||||
|
*x = GetLastDeployTimeReply{}
|
||||||
|
mi := &file_galaxy_repository_proto_msgTypes[3]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetLastDeployTimeReply) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*GetLastDeployTimeReply) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *GetLastDeployTimeReply) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_galaxy_repository_proto_msgTypes[3]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use GetLastDeployTimeReply.ProtoReflect.Descriptor instead.
|
||||||
|
func (*GetLastDeployTimeReply) Descriptor() ([]byte, []int) {
|
||||||
|
return file_galaxy_repository_proto_rawDescGZIP(), []int{3}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetLastDeployTimeReply) GetPresent() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.Present
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetLastDeployTimeReply) GetTimeOfLastDeploy() *timestamppb.Timestamp {
|
||||||
|
if x != nil {
|
||||||
|
return x.TimeOfLastDeploy
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiscoverHierarchyRequest struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
// Maximum number of objects to return. The server applies its default when
|
||||||
|
// unset and rejects non-positive values.
|
||||||
|
PageSize int32 `protobuf:"varint,1,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
|
||||||
|
// Opaque token returned by a previous DiscoverHierarchy response.
|
||||||
|
PageToken string `protobuf:"bytes,2,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"`
|
||||||
|
// Optional. When set, return only this object and its descendants.
|
||||||
|
// Empty = full hierarchy.
|
||||||
|
//
|
||||||
|
// Types that are valid to be assigned to Root:
|
||||||
|
//
|
||||||
|
// *DiscoverHierarchyRequest_RootGobjectId
|
||||||
|
// *DiscoverHierarchyRequest_RootTagName
|
||||||
|
// *DiscoverHierarchyRequest_RootContainedPath
|
||||||
|
Root isDiscoverHierarchyRequest_Root `protobuf_oneof:"root"`
|
||||||
|
// Optional. Cap on descendant depth from root. Zero returns only the root.
|
||||||
|
// Unset means unlimited depth.
|
||||||
|
MaxDepth *wrapperspb.Int32Value `protobuf:"bytes,6,opt,name=max_depth,json=maxDepth,proto3" json:"max_depth,omitempty"`
|
||||||
|
// Optional object category id filters.
|
||||||
|
CategoryIds []int32 `protobuf:"varint,7,rep,packed,name=category_ids,json=categoryIds,proto3" json:"category_ids,omitempty"`
|
||||||
|
// Optional case-insensitive substring filters against template names.
|
||||||
|
TemplateChainContains []string `protobuf:"bytes,8,rep,name=template_chain_contains,json=templateChainContains,proto3" json:"template_chain_contains,omitempty"`
|
||||||
|
// Optional anchored, case-insensitive glob over object tag_name.
|
||||||
|
TagNameGlob string `protobuf:"bytes,9,opt,name=tag_name_glob,json=tagNameGlob,proto3" json:"tag_name_glob,omitempty"`
|
||||||
|
// Optional. Unset or true includes attributes. False returns object skeletons.
|
||||||
|
IncludeAttributes *bool `protobuf:"varint,10,opt,name=include_attributes,json=includeAttributes,proto3,oneof" json:"include_attributes,omitempty"`
|
||||||
|
// Optional. Return only objects with at least one alarm-bearing attribute.
|
||||||
|
AlarmBearingOnly bool `protobuf:"varint,11,opt,name=alarm_bearing_only,json=alarmBearingOnly,proto3" json:"alarm_bearing_only,omitempty"`
|
||||||
|
// Optional. Return only objects with at least one historized attribute.
|
||||||
|
HistorizedOnly bool `protobuf:"varint,12,opt,name=historized_only,json=historizedOnly,proto3" json:"historized_only,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DiscoverHierarchyRequest) Reset() {
|
||||||
|
*x = DiscoverHierarchyRequest{}
|
||||||
|
mi := &file_galaxy_repository_proto_msgTypes[4]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DiscoverHierarchyRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*DiscoverHierarchyRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *DiscoverHierarchyRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_galaxy_repository_proto_msgTypes[4]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use DiscoverHierarchyRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*DiscoverHierarchyRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_galaxy_repository_proto_rawDescGZIP(), []int{4}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DiscoverHierarchyRequest) GetPageSize() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.PageSize
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DiscoverHierarchyRequest) GetPageToken() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.PageToken
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DiscoverHierarchyRequest) GetRoot() isDiscoverHierarchyRequest_Root {
|
||||||
|
if x != nil {
|
||||||
|
return x.Root
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DiscoverHierarchyRequest) GetRootGobjectId() int32 {
|
||||||
|
if x != nil {
|
||||||
|
if x, ok := x.Root.(*DiscoverHierarchyRequest_RootGobjectId); ok {
|
||||||
|
return x.RootGobjectId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DiscoverHierarchyRequest) GetRootTagName() string {
|
||||||
|
if x != nil {
|
||||||
|
if x, ok := x.Root.(*DiscoverHierarchyRequest_RootTagName); ok {
|
||||||
|
return x.RootTagName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DiscoverHierarchyRequest) GetRootContainedPath() string {
|
||||||
|
if x != nil {
|
||||||
|
if x, ok := x.Root.(*DiscoverHierarchyRequest_RootContainedPath); ok {
|
||||||
|
return x.RootContainedPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DiscoverHierarchyRequest) GetMaxDepth() *wrapperspb.Int32Value {
|
||||||
|
if x != nil {
|
||||||
|
return x.MaxDepth
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DiscoverHierarchyRequest) GetCategoryIds() []int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.CategoryIds
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DiscoverHierarchyRequest) GetTemplateChainContains() []string {
|
||||||
|
if x != nil {
|
||||||
|
return x.TemplateChainContains
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DiscoverHierarchyRequest) GetTagNameGlob() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.TagNameGlob
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DiscoverHierarchyRequest) GetIncludeAttributes() bool {
|
||||||
|
if x != nil && x.IncludeAttributes != nil {
|
||||||
|
return *x.IncludeAttributes
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DiscoverHierarchyRequest) GetAlarmBearingOnly() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.AlarmBearingOnly
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DiscoverHierarchyRequest) GetHistorizedOnly() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.HistorizedOnly
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type isDiscoverHierarchyRequest_Root interface {
|
||||||
|
isDiscoverHierarchyRequest_Root()
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiscoverHierarchyRequest_RootGobjectId struct {
|
||||||
|
RootGobjectId int32 `protobuf:"varint,3,opt,name=root_gobject_id,json=rootGobjectId,proto3,oneof"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiscoverHierarchyRequest_RootTagName struct {
|
||||||
|
RootTagName string `protobuf:"bytes,4,opt,name=root_tag_name,json=rootTagName,proto3,oneof"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiscoverHierarchyRequest_RootContainedPath struct {
|
||||||
|
RootContainedPath string `protobuf:"bytes,5,opt,name=root_contained_path,json=rootContainedPath,proto3,oneof"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*DiscoverHierarchyRequest_RootGobjectId) isDiscoverHierarchyRequest_Root() {}
|
||||||
|
|
||||||
|
func (*DiscoverHierarchyRequest_RootTagName) isDiscoverHierarchyRequest_Root() {}
|
||||||
|
|
||||||
|
func (*DiscoverHierarchyRequest_RootContainedPath) isDiscoverHierarchyRequest_Root() {}
|
||||||
|
|
||||||
|
type DiscoverHierarchyReply struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Objects []*GalaxyObject `protobuf:"bytes,1,rep,name=objects,proto3" json:"objects,omitempty"`
|
||||||
|
// Non-empty when another page is available.
|
||||||
|
NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"`
|
||||||
|
// Total number of objects in the cached hierarchy at the time of the call.
|
||||||
|
TotalObjectCount int32 `protobuf:"varint,3,opt,name=total_object_count,json=totalObjectCount,proto3" json:"total_object_count,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DiscoverHierarchyReply) Reset() {
|
||||||
|
*x = DiscoverHierarchyReply{}
|
||||||
|
mi := &file_galaxy_repository_proto_msgTypes[5]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DiscoverHierarchyReply) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*DiscoverHierarchyReply) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *DiscoverHierarchyReply) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_galaxy_repository_proto_msgTypes[5]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use DiscoverHierarchyReply.ProtoReflect.Descriptor instead.
|
||||||
|
func (*DiscoverHierarchyReply) Descriptor() ([]byte, []int) {
|
||||||
|
return file_galaxy_repository_proto_rawDescGZIP(), []int{5}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DiscoverHierarchyReply) GetObjects() []*GalaxyObject {
|
||||||
|
if x != nil {
|
||||||
|
return x.Objects
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DiscoverHierarchyReply) GetNextPageToken() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.NextPageToken
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DiscoverHierarchyReply) GetTotalObjectCount() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.TotalObjectCount
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type WatchDeployEventsRequest struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
// Optional. When set, the bootstrap event is suppressed if the cached deploy
|
||||||
|
// time matches this value. Future events are still emitted normally.
|
||||||
|
LastSeenDeployTime *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=last_seen_deploy_time,json=lastSeenDeployTime,proto3" json:"last_seen_deploy_time,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *WatchDeployEventsRequest) Reset() {
|
||||||
|
*x = WatchDeployEventsRequest{}
|
||||||
|
mi := &file_galaxy_repository_proto_msgTypes[6]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *WatchDeployEventsRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*WatchDeployEventsRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *WatchDeployEventsRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_galaxy_repository_proto_msgTypes[6]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use WatchDeployEventsRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*WatchDeployEventsRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_galaxy_repository_proto_rawDescGZIP(), []int{6}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *WatchDeployEventsRequest) GetLastSeenDeployTime() *timestamppb.Timestamp {
|
||||||
|
if x != nil {
|
||||||
|
return x.LastSeenDeployTime
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeployEvent struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
// Monotonically increasing per server start. Gaps indicate dropped events.
|
||||||
|
Sequence uint64 `protobuf:"varint,1,opt,name=sequence,proto3" json:"sequence,omitempty"`
|
||||||
|
// Server wall-clock when the cache observed the deploy.
|
||||||
|
ObservedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=observed_at,json=observedAt,proto3" json:"observed_at,omitempty"`
|
||||||
|
// Galaxy.time_of_last_deploy. Absent only when the Galaxy table reports null.
|
||||||
|
TimeOfLastDeploy *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=time_of_last_deploy,json=timeOfLastDeploy,proto3" json:"time_of_last_deploy,omitempty"`
|
||||||
|
TimeOfLastDeployPresent bool `protobuf:"varint,4,opt,name=time_of_last_deploy_present,json=timeOfLastDeployPresent,proto3" json:"time_of_last_deploy_present,omitempty"`
|
||||||
|
ObjectCount int32 `protobuf:"varint,5,opt,name=object_count,json=objectCount,proto3" json:"object_count,omitempty"`
|
||||||
|
AttributeCount int32 `protobuf:"varint,6,opt,name=attribute_count,json=attributeCount,proto3" json:"attribute_count,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DeployEvent) Reset() {
|
||||||
|
*x = DeployEvent{}
|
||||||
|
mi := &file_galaxy_repository_proto_msgTypes[7]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DeployEvent) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*DeployEvent) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *DeployEvent) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_galaxy_repository_proto_msgTypes[7]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use DeployEvent.ProtoReflect.Descriptor instead.
|
||||||
|
func (*DeployEvent) Descriptor() ([]byte, []int) {
|
||||||
|
return file_galaxy_repository_proto_rawDescGZIP(), []int{7}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DeployEvent) GetSequence() uint64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Sequence
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DeployEvent) GetObservedAt() *timestamppb.Timestamp {
|
||||||
|
if x != nil {
|
||||||
|
return x.ObservedAt
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DeployEvent) GetTimeOfLastDeploy() *timestamppb.Timestamp {
|
||||||
|
if x != nil {
|
||||||
|
return x.TimeOfLastDeploy
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DeployEvent) GetTimeOfLastDeployPresent() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.TimeOfLastDeployPresent
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DeployEvent) GetObjectCount() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.ObjectCount
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DeployEvent) GetAttributeCount() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.AttributeCount
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type GalaxyObject struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
GobjectId int32 `protobuf:"varint,1,opt,name=gobject_id,json=gobjectId,proto3" json:"gobject_id,omitempty"`
|
||||||
|
TagName string `protobuf:"bytes,2,opt,name=tag_name,json=tagName,proto3" json:"tag_name,omitempty"`
|
||||||
|
ContainedName string `protobuf:"bytes,3,opt,name=contained_name,json=containedName,proto3" json:"contained_name,omitempty"`
|
||||||
|
BrowseName string `protobuf:"bytes,4,opt,name=browse_name,json=browseName,proto3" json:"browse_name,omitempty"`
|
||||||
|
ParentGobjectId int32 `protobuf:"varint,5,opt,name=parent_gobject_id,json=parentGobjectId,proto3" json:"parent_gobject_id,omitempty"`
|
||||||
|
IsArea bool `protobuf:"varint,6,opt,name=is_area,json=isArea,proto3" json:"is_area,omitempty"`
|
||||||
|
CategoryId int32 `protobuf:"varint,7,opt,name=category_id,json=categoryId,proto3" json:"category_id,omitempty"`
|
||||||
|
HostedByGobjectId int32 `protobuf:"varint,8,opt,name=hosted_by_gobject_id,json=hostedByGobjectId,proto3" json:"hosted_by_gobject_id,omitempty"`
|
||||||
|
TemplateChain []string `protobuf:"bytes,9,rep,name=template_chain,json=templateChain,proto3" json:"template_chain,omitempty"`
|
||||||
|
Attributes []*GalaxyAttribute `protobuf:"bytes,10,rep,name=attributes,proto3" json:"attributes,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GalaxyObject) Reset() {
|
||||||
|
*x = GalaxyObject{}
|
||||||
|
mi := &file_galaxy_repository_proto_msgTypes[8]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GalaxyObject) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*GalaxyObject) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *GalaxyObject) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_galaxy_repository_proto_msgTypes[8]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use GalaxyObject.ProtoReflect.Descriptor instead.
|
||||||
|
func (*GalaxyObject) Descriptor() ([]byte, []int) {
|
||||||
|
return file_galaxy_repository_proto_rawDescGZIP(), []int{8}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GalaxyObject) GetGobjectId() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.GobjectId
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GalaxyObject) GetTagName() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.TagName
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GalaxyObject) GetContainedName() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.ContainedName
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GalaxyObject) GetBrowseName() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.BrowseName
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GalaxyObject) GetParentGobjectId() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.ParentGobjectId
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GalaxyObject) GetIsArea() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.IsArea
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GalaxyObject) GetCategoryId() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.CategoryId
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GalaxyObject) GetHostedByGobjectId() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.HostedByGobjectId
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GalaxyObject) GetTemplateChain() []string {
|
||||||
|
if x != nil {
|
||||||
|
return x.TemplateChain
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GalaxyObject) GetAttributes() []*GalaxyAttribute {
|
||||||
|
if x != nil {
|
||||||
|
return x.Attributes
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type GalaxyAttribute struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
AttributeName string `protobuf:"bytes,1,opt,name=attribute_name,json=attributeName,proto3" json:"attribute_name,omitempty"`
|
||||||
|
FullTagReference string `protobuf:"bytes,2,opt,name=full_tag_reference,json=fullTagReference,proto3" json:"full_tag_reference,omitempty"`
|
||||||
|
// Raw Galaxy SQL `dbo.data_type` identifier, passed through unchanged.
|
||||||
|
// This is NOT a member of `mxaccess_gateway.v1.MxDataType` — Galaxy's
|
||||||
|
// type enumeration is distinct from MXAccess's wire data-type enum and
|
||||||
|
// the two must not be cast or compared. The GalaxyRepository service is
|
||||||
|
// metadata-only and deliberately does not share types with
|
||||||
|
// mxaccess_gateway.proto. See docs/GalaxyRepository.md.
|
||||||
|
MxDataType int32 `protobuf:"varint,3,opt,name=mx_data_type,json=mxDataType,proto3" json:"mx_data_type,omitempty"`
|
||||||
|
// Human-readable name from Galaxy's `dbo.data_type` table (e.g. "Float",
|
||||||
|
// "Integer", "Boolean"). Free-form Galaxy text; not a stable enum.
|
||||||
|
DataTypeName string `protobuf:"bytes,4,opt,name=data_type_name,json=dataTypeName,proto3" json:"data_type_name,omitempty"`
|
||||||
|
IsArray bool `protobuf:"varint,5,opt,name=is_array,json=isArray,proto3" json:"is_array,omitempty"`
|
||||||
|
ArrayDimension int32 `protobuf:"varint,6,opt,name=array_dimension,json=arrayDimension,proto3" json:"array_dimension,omitempty"`
|
||||||
|
ArrayDimensionPresent bool `protobuf:"varint,7,opt,name=array_dimension_present,json=arrayDimensionPresent,proto3" json:"array_dimension_present,omitempty"`
|
||||||
|
// Raw Galaxy SQL attribute-category identifier, passed through unchanged.
|
||||||
|
// Galaxy-specific; not mapped to any gateway enum. See
|
||||||
|
// docs/GalaxyRepository.md.
|
||||||
|
MxAttributeCategory int32 `protobuf:"varint,8,opt,name=mx_attribute_category,json=mxAttributeCategory,proto3" json:"mx_attribute_category,omitempty"`
|
||||||
|
// Raw Galaxy SQL security-classification identifier, passed through
|
||||||
|
// unchanged. Galaxy-specific; not mapped to any gateway enum. See
|
||||||
|
// docs/GalaxyRepository.md.
|
||||||
|
SecurityClassification int32 `protobuf:"varint,9,opt,name=security_classification,json=securityClassification,proto3" json:"security_classification,omitempty"`
|
||||||
|
IsHistorized bool `protobuf:"varint,10,opt,name=is_historized,json=isHistorized,proto3" json:"is_historized,omitempty"`
|
||||||
|
IsAlarm bool `protobuf:"varint,11,opt,name=is_alarm,json=isAlarm,proto3" json:"is_alarm,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GalaxyAttribute) Reset() {
|
||||||
|
*x = GalaxyAttribute{}
|
||||||
|
mi := &file_galaxy_repository_proto_msgTypes[9]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GalaxyAttribute) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*GalaxyAttribute) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *GalaxyAttribute) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_galaxy_repository_proto_msgTypes[9]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use GalaxyAttribute.ProtoReflect.Descriptor instead.
|
||||||
|
func (*GalaxyAttribute) Descriptor() ([]byte, []int) {
|
||||||
|
return file_galaxy_repository_proto_rawDescGZIP(), []int{9}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GalaxyAttribute) GetAttributeName() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.AttributeName
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GalaxyAttribute) GetFullTagReference() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.FullTagReference
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GalaxyAttribute) GetMxDataType() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.MxDataType
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GalaxyAttribute) GetDataTypeName() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.DataTypeName
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GalaxyAttribute) GetIsArray() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.IsArray
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GalaxyAttribute) GetArrayDimension() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.ArrayDimension
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GalaxyAttribute) GetArrayDimensionPresent() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.ArrayDimensionPresent
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GalaxyAttribute) GetMxAttributeCategory() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.MxAttributeCategory
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GalaxyAttribute) GetSecurityClassification() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.SecurityClassification
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GalaxyAttribute) GetIsHistorized() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.IsHistorized
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GalaxyAttribute) GetIsAlarm() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.IsAlarm
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var File_galaxy_repository_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
|
const file_galaxy_repository_proto_rawDesc = "" +
|
||||||
|
"\n" +
|
||||||
|
"\x17galaxy_repository.proto\x12\x14galaxy_repository.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/wrappers.proto\"\x17\n" +
|
||||||
|
"\x15TestConnectionRequest\"%\n" +
|
||||||
|
"\x13TestConnectionReply\x12\x0e\n" +
|
||||||
|
"\x02ok\x18\x01 \x01(\bR\x02ok\"\x1a\n" +
|
||||||
|
"\x18GetLastDeployTimeRequest\"}\n" +
|
||||||
|
"\x16GetLastDeployTimeReply\x12\x18\n" +
|
||||||
|
"\apresent\x18\x01 \x01(\bR\apresent\x12I\n" +
|
||||||
|
"\x13time_of_last_deploy\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x10timeOfLastDeploy\"\xbb\x04\n" +
|
||||||
|
"\x18DiscoverHierarchyRequest\x12\x1b\n" +
|
||||||
|
"\tpage_size\x18\x01 \x01(\x05R\bpageSize\x12\x1d\n" +
|
||||||
|
"\n" +
|
||||||
|
"page_token\x18\x02 \x01(\tR\tpageToken\x12(\n" +
|
||||||
|
"\x0froot_gobject_id\x18\x03 \x01(\x05H\x00R\rrootGobjectId\x12$\n" +
|
||||||
|
"\rroot_tag_name\x18\x04 \x01(\tH\x00R\vrootTagName\x120\n" +
|
||||||
|
"\x13root_contained_path\x18\x05 \x01(\tH\x00R\x11rootContainedPath\x128\n" +
|
||||||
|
"\tmax_depth\x18\x06 \x01(\v2\x1b.google.protobuf.Int32ValueR\bmaxDepth\x12!\n" +
|
||||||
|
"\fcategory_ids\x18\a \x03(\x05R\vcategoryIds\x126\n" +
|
||||||
|
"\x17template_chain_contains\x18\b \x03(\tR\x15templateChainContains\x12\"\n" +
|
||||||
|
"\rtag_name_glob\x18\t \x01(\tR\vtagNameGlob\x122\n" +
|
||||||
|
"\x12include_attributes\x18\n" +
|
||||||
|
" \x01(\bH\x01R\x11includeAttributes\x88\x01\x01\x12,\n" +
|
||||||
|
"\x12alarm_bearing_only\x18\v \x01(\bR\x10alarmBearingOnly\x12'\n" +
|
||||||
|
"\x0fhistorized_only\x18\f \x01(\bR\x0ehistorizedOnlyB\x06\n" +
|
||||||
|
"\x04rootB\x15\n" +
|
||||||
|
"\x13_include_attributes\"\xac\x01\n" +
|
||||||
|
"\x16DiscoverHierarchyReply\x12<\n" +
|
||||||
|
"\aobjects\x18\x01 \x03(\v2\".galaxy_repository.v1.GalaxyObjectR\aobjects\x12&\n" +
|
||||||
|
"\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\x12,\n" +
|
||||||
|
"\x12total_object_count\x18\x03 \x01(\x05R\x10totalObjectCount\"i\n" +
|
||||||
|
"\x18WatchDeployEventsRequest\x12M\n" +
|
||||||
|
"\x15last_seen_deploy_time\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\x12lastSeenDeployTime\"\xbb\x02\n" +
|
||||||
|
"\vDeployEvent\x12\x1a\n" +
|
||||||
|
"\bsequence\x18\x01 \x01(\x04R\bsequence\x12;\n" +
|
||||||
|
"\vobserved_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\n" +
|
||||||
|
"observedAt\x12I\n" +
|
||||||
|
"\x13time_of_last_deploy\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\x10timeOfLastDeploy\x12<\n" +
|
||||||
|
"\x1btime_of_last_deploy_present\x18\x04 \x01(\bR\x17timeOfLastDeployPresent\x12!\n" +
|
||||||
|
"\fobject_count\x18\x05 \x01(\x05R\vobjectCount\x12'\n" +
|
||||||
|
"\x0fattribute_count\x18\x06 \x01(\x05R\x0eattributeCount\"\x95\x03\n" +
|
||||||
|
"\fGalaxyObject\x12\x1d\n" +
|
||||||
|
"\n" +
|
||||||
|
"gobject_id\x18\x01 \x01(\x05R\tgobjectId\x12\x19\n" +
|
||||||
|
"\btag_name\x18\x02 \x01(\tR\atagName\x12%\n" +
|
||||||
|
"\x0econtained_name\x18\x03 \x01(\tR\rcontainedName\x12\x1f\n" +
|
||||||
|
"\vbrowse_name\x18\x04 \x01(\tR\n" +
|
||||||
|
"browseName\x12*\n" +
|
||||||
|
"\x11parent_gobject_id\x18\x05 \x01(\x05R\x0fparentGobjectId\x12\x17\n" +
|
||||||
|
"\ais_area\x18\x06 \x01(\bR\x06isArea\x12\x1f\n" +
|
||||||
|
"\vcategory_id\x18\a \x01(\x05R\n" +
|
||||||
|
"categoryId\x12/\n" +
|
||||||
|
"\x14hosted_by_gobject_id\x18\b \x01(\x05R\x11hostedByGobjectId\x12%\n" +
|
||||||
|
"\x0etemplate_chain\x18\t \x03(\tR\rtemplateChain\x12E\n" +
|
||||||
|
"\n" +
|
||||||
|
"attributes\x18\n" +
|
||||||
|
" \x03(\v2%.galaxy_repository.v1.GalaxyAttributeR\n" +
|
||||||
|
"attributes\"\xd7\x03\n" +
|
||||||
|
"\x0fGalaxyAttribute\x12%\n" +
|
||||||
|
"\x0eattribute_name\x18\x01 \x01(\tR\rattributeName\x12,\n" +
|
||||||
|
"\x12full_tag_reference\x18\x02 \x01(\tR\x10fullTagReference\x12 \n" +
|
||||||
|
"\fmx_data_type\x18\x03 \x01(\x05R\n" +
|
||||||
|
"mxDataType\x12$\n" +
|
||||||
|
"\x0edata_type_name\x18\x04 \x01(\tR\fdataTypeName\x12\x19\n" +
|
||||||
|
"\bis_array\x18\x05 \x01(\bR\aisArray\x12'\n" +
|
||||||
|
"\x0farray_dimension\x18\x06 \x01(\x05R\x0earrayDimension\x126\n" +
|
||||||
|
"\x17array_dimension_present\x18\a \x01(\bR\x15arrayDimensionPresent\x122\n" +
|
||||||
|
"\x15mx_attribute_category\x18\b \x01(\x05R\x13mxAttributeCategory\x127\n" +
|
||||||
|
"\x17security_classification\x18\t \x01(\x05R\x16securityClassification\x12#\n" +
|
||||||
|
"\ris_historized\x18\n" +
|
||||||
|
" \x01(\bR\fisHistorized\x12\x19\n" +
|
||||||
|
"\bis_alarm\x18\v \x01(\bR\aisAlarm2\xcc\x03\n" +
|
||||||
|
"\x10GalaxyRepository\x12h\n" +
|
||||||
|
"\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n" +
|
||||||
|
"\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n" +
|
||||||
|
"\x11DiscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n" +
|
||||||
|
"\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01B#\xaa\x02 MxGateway.Contracts.Proto.Galaxyb\x06proto3"
|
||||||
|
|
||||||
|
var (
|
||||||
|
file_galaxy_repository_proto_rawDescOnce sync.Once
|
||||||
|
file_galaxy_repository_proto_rawDescData []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
func file_galaxy_repository_proto_rawDescGZIP() []byte {
|
||||||
|
file_galaxy_repository_proto_rawDescOnce.Do(func() {
|
||||||
|
file_galaxy_repository_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_galaxy_repository_proto_rawDesc), len(file_galaxy_repository_proto_rawDesc)))
|
||||||
|
})
|
||||||
|
return file_galaxy_repository_proto_rawDescData
|
||||||
|
}
|
||||||
|
|
||||||
|
var file_galaxy_repository_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
|
||||||
|
var file_galaxy_repository_proto_goTypes = []any{
|
||||||
|
(*TestConnectionRequest)(nil), // 0: galaxy_repository.v1.TestConnectionRequest
|
||||||
|
(*TestConnectionReply)(nil), // 1: galaxy_repository.v1.TestConnectionReply
|
||||||
|
(*GetLastDeployTimeRequest)(nil), // 2: galaxy_repository.v1.GetLastDeployTimeRequest
|
||||||
|
(*GetLastDeployTimeReply)(nil), // 3: galaxy_repository.v1.GetLastDeployTimeReply
|
||||||
|
(*DiscoverHierarchyRequest)(nil), // 4: galaxy_repository.v1.DiscoverHierarchyRequest
|
||||||
|
(*DiscoverHierarchyReply)(nil), // 5: galaxy_repository.v1.DiscoverHierarchyReply
|
||||||
|
(*WatchDeployEventsRequest)(nil), // 6: galaxy_repository.v1.WatchDeployEventsRequest
|
||||||
|
(*DeployEvent)(nil), // 7: galaxy_repository.v1.DeployEvent
|
||||||
|
(*GalaxyObject)(nil), // 8: galaxy_repository.v1.GalaxyObject
|
||||||
|
(*GalaxyAttribute)(nil), // 9: galaxy_repository.v1.GalaxyAttribute
|
||||||
|
(*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp
|
||||||
|
(*wrapperspb.Int32Value)(nil), // 11: google.protobuf.Int32Value
|
||||||
|
}
|
||||||
|
var file_galaxy_repository_proto_depIdxs = []int32{
|
||||||
|
10, // 0: galaxy_repository.v1.GetLastDeployTimeReply.time_of_last_deploy:type_name -> google.protobuf.Timestamp
|
||||||
|
11, // 1: galaxy_repository.v1.DiscoverHierarchyRequest.max_depth:type_name -> google.protobuf.Int32Value
|
||||||
|
8, // 2: galaxy_repository.v1.DiscoverHierarchyReply.objects:type_name -> galaxy_repository.v1.GalaxyObject
|
||||||
|
10, // 3: galaxy_repository.v1.WatchDeployEventsRequest.last_seen_deploy_time:type_name -> google.protobuf.Timestamp
|
||||||
|
10, // 4: galaxy_repository.v1.DeployEvent.observed_at:type_name -> google.protobuf.Timestamp
|
||||||
|
10, // 5: galaxy_repository.v1.DeployEvent.time_of_last_deploy:type_name -> google.protobuf.Timestamp
|
||||||
|
9, // 6: galaxy_repository.v1.GalaxyObject.attributes:type_name -> galaxy_repository.v1.GalaxyAttribute
|
||||||
|
0, // 7: galaxy_repository.v1.GalaxyRepository.TestConnection:input_type -> galaxy_repository.v1.TestConnectionRequest
|
||||||
|
2, // 8: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:input_type -> galaxy_repository.v1.GetLastDeployTimeRequest
|
||||||
|
4, // 9: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:input_type -> galaxy_repository.v1.DiscoverHierarchyRequest
|
||||||
|
6, // 10: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:input_type -> galaxy_repository.v1.WatchDeployEventsRequest
|
||||||
|
1, // 11: galaxy_repository.v1.GalaxyRepository.TestConnection:output_type -> galaxy_repository.v1.TestConnectionReply
|
||||||
|
3, // 12: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:output_type -> galaxy_repository.v1.GetLastDeployTimeReply
|
||||||
|
5, // 13: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:output_type -> galaxy_repository.v1.DiscoverHierarchyReply
|
||||||
|
7, // 14: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:output_type -> galaxy_repository.v1.DeployEvent
|
||||||
|
11, // [11:15] is the sub-list for method output_type
|
||||||
|
7, // [7:11] is the sub-list for method input_type
|
||||||
|
7, // [7:7] is the sub-list for extension type_name
|
||||||
|
7, // [7:7] is the sub-list for extension extendee
|
||||||
|
0, // [0:7] is the sub-list for field type_name
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { file_galaxy_repository_proto_init() }
|
||||||
|
func file_galaxy_repository_proto_init() {
|
||||||
|
if File_galaxy_repository_proto != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file_galaxy_repository_proto_msgTypes[4].OneofWrappers = []any{
|
||||||
|
(*DiscoverHierarchyRequest_RootGobjectId)(nil),
|
||||||
|
(*DiscoverHierarchyRequest_RootTagName)(nil),
|
||||||
|
(*DiscoverHierarchyRequest_RootContainedPath)(nil),
|
||||||
|
}
|
||||||
|
type x struct{}
|
||||||
|
out := protoimpl.TypeBuilder{
|
||||||
|
File: protoimpl.DescBuilder{
|
||||||
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_galaxy_repository_proto_rawDesc), len(file_galaxy_repository_proto_rawDesc)),
|
||||||
|
NumEnums: 0,
|
||||||
|
NumMessages: 10,
|
||||||
|
NumExtensions: 0,
|
||||||
|
NumServices: 1,
|
||||||
|
},
|
||||||
|
GoTypes: file_galaxy_repository_proto_goTypes,
|
||||||
|
DependencyIndexes: file_galaxy_repository_proto_depIdxs,
|
||||||
|
MessageInfos: file_galaxy_repository_proto_msgTypes,
|
||||||
|
}.Build()
|
||||||
|
File_galaxy_repository_proto = out.File
|
||||||
|
file_galaxy_repository_proto_goTypes = nil
|
||||||
|
file_galaxy_repository_proto_depIdxs = nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// - protoc-gen-go-grpc v1.6.1
|
||||||
|
// - protoc v7.34.1
|
||||||
|
// source: galaxy_repository.proto
|
||||||
|
|
||||||
|
package generated
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
grpc "google.golang.org/grpc"
|
||||||
|
codes "google.golang.org/grpc/codes"
|
||||||
|
status "google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This is a compile-time assertion to ensure that this generated file
|
||||||
|
// is compatible with the grpc package it is being compiled against.
|
||||||
|
// Requires gRPC-Go v1.64.0 or later.
|
||||||
|
const _ = grpc.SupportPackageIsVersion9
|
||||||
|
|
||||||
|
const (
|
||||||
|
GalaxyRepository_TestConnection_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/TestConnection"
|
||||||
|
GalaxyRepository_GetLastDeployTime_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/GetLastDeployTime"
|
||||||
|
GalaxyRepository_DiscoverHierarchy_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/DiscoverHierarchy"
|
||||||
|
GalaxyRepository_WatchDeployEvents_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/WatchDeployEvents"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GalaxyRepositoryClient is the client API for GalaxyRepository service.
|
||||||
|
//
|
||||||
|
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||||
|
//
|
||||||
|
// Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
|
||||||
|
// database). Lets clients enumerate the deployed object hierarchy and each
|
||||||
|
// object's dynamic attributes so they know what tag references to subscribe
|
||||||
|
// to via the MxAccessGateway service.
|
||||||
|
type GalaxyRepositoryClient interface {
|
||||||
|
TestConnection(ctx context.Context, in *TestConnectionRequest, opts ...grpc.CallOption) (*TestConnectionReply, error)
|
||||||
|
GetLastDeployTime(ctx context.Context, in *GetLastDeployTimeRequest, opts ...grpc.CallOption) (*GetLastDeployTimeReply, error)
|
||||||
|
DiscoverHierarchy(ctx context.Context, in *DiscoverHierarchyRequest, opts ...grpc.CallOption) (*DiscoverHierarchyReply, error)
|
||||||
|
// Server-stream of deploy events. The server emits the current state immediately
|
||||||
|
// on subscribe (so clients can bootstrap their cache without waiting for the next
|
||||||
|
// deploy), then emits one event each time the gateway's hierarchy cache observes
|
||||||
|
// a new galaxy.time_of_last_deploy. The sequence field is monotonically
|
||||||
|
// increasing per server start; gaps indicate the per-subscriber buffer dropped
|
||||||
|
// older events because the client was too slow.
|
||||||
|
WatchDeployEvents(ctx context.Context, in *WatchDeployEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[DeployEvent], error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type galaxyRepositoryClient struct {
|
||||||
|
cc grpc.ClientConnInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGalaxyRepositoryClient(cc grpc.ClientConnInterface) GalaxyRepositoryClient {
|
||||||
|
return &galaxyRepositoryClient{cc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *galaxyRepositoryClient) TestConnection(ctx context.Context, in *TestConnectionRequest, opts ...grpc.CallOption) (*TestConnectionReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(TestConnectionReply)
|
||||||
|
err := c.cc.Invoke(ctx, GalaxyRepository_TestConnection_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *galaxyRepositoryClient) GetLastDeployTime(ctx context.Context, in *GetLastDeployTimeRequest, opts ...grpc.CallOption) (*GetLastDeployTimeReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(GetLastDeployTimeReply)
|
||||||
|
err := c.cc.Invoke(ctx, GalaxyRepository_GetLastDeployTime_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *galaxyRepositoryClient) DiscoverHierarchy(ctx context.Context, in *DiscoverHierarchyRequest, opts ...grpc.CallOption) (*DiscoverHierarchyReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(DiscoverHierarchyReply)
|
||||||
|
err := c.cc.Invoke(ctx, GalaxyRepository_DiscoverHierarchy_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *galaxyRepositoryClient) WatchDeployEvents(ctx context.Context, in *WatchDeployEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[DeployEvent], error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
stream, err := c.cc.NewStream(ctx, &GalaxyRepository_ServiceDesc.Streams[0], GalaxyRepository_WatchDeployEvents_FullMethodName, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
x := &grpc.GenericClientStream[WatchDeployEventsRequest, DeployEvent]{ClientStream: stream}
|
||||||
|
if err := x.ClientStream.SendMsg(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := x.ClientStream.CloseSend(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return x, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
|
type GalaxyRepository_WatchDeployEventsClient = grpc.ServerStreamingClient[DeployEvent]
|
||||||
|
|
||||||
|
// GalaxyRepositoryServer is the server API for GalaxyRepository service.
|
||||||
|
// All implementations must embed UnimplementedGalaxyRepositoryServer
|
||||||
|
// for forward compatibility.
|
||||||
|
//
|
||||||
|
// Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
|
||||||
|
// database). Lets clients enumerate the deployed object hierarchy and each
|
||||||
|
// object's dynamic attributes so they know what tag references to subscribe
|
||||||
|
// to via the MxAccessGateway service.
|
||||||
|
type GalaxyRepositoryServer interface {
|
||||||
|
TestConnection(context.Context, *TestConnectionRequest) (*TestConnectionReply, error)
|
||||||
|
GetLastDeployTime(context.Context, *GetLastDeployTimeRequest) (*GetLastDeployTimeReply, error)
|
||||||
|
DiscoverHierarchy(context.Context, *DiscoverHierarchyRequest) (*DiscoverHierarchyReply, error)
|
||||||
|
// Server-stream of deploy events. The server emits the current state immediately
|
||||||
|
// on subscribe (so clients can bootstrap their cache without waiting for the next
|
||||||
|
// deploy), then emits one event each time the gateway's hierarchy cache observes
|
||||||
|
// a new galaxy.time_of_last_deploy. The sequence field is monotonically
|
||||||
|
// increasing per server start; gaps indicate the per-subscriber buffer dropped
|
||||||
|
// older events because the client was too slow.
|
||||||
|
WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error
|
||||||
|
mustEmbedUnimplementedGalaxyRepositoryServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnimplementedGalaxyRepositoryServer must be embedded to have
|
||||||
|
// forward compatible implementations.
|
||||||
|
//
|
||||||
|
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||||
|
// pointer dereference when methods are called.
|
||||||
|
type UnimplementedGalaxyRepositoryServer struct{}
|
||||||
|
|
||||||
|
func (UnimplementedGalaxyRepositoryServer) TestConnection(context.Context, *TestConnectionRequest) (*TestConnectionReply, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method TestConnection not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedGalaxyRepositoryServer) GetLastDeployTime(context.Context, *GetLastDeployTimeRequest) (*GetLastDeployTimeReply, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method GetLastDeployTime not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedGalaxyRepositoryServer) DiscoverHierarchy(context.Context, *DiscoverHierarchyRequest) (*DiscoverHierarchyReply, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method DiscoverHierarchy not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedGalaxyRepositoryServer) WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error {
|
||||||
|
return status.Error(codes.Unimplemented, "method WatchDeployEvents not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedGalaxyRepositoryServer) mustEmbedUnimplementedGalaxyRepositoryServer() {}
|
||||||
|
func (UnimplementedGalaxyRepositoryServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
|
// UnsafeGalaxyRepositoryServer may be embedded to opt out of forward compatibility for this service.
|
||||||
|
// Use of this interface is not recommended, as added methods to GalaxyRepositoryServer will
|
||||||
|
// result in compilation errors.
|
||||||
|
type UnsafeGalaxyRepositoryServer interface {
|
||||||
|
mustEmbedUnimplementedGalaxyRepositoryServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterGalaxyRepositoryServer(s grpc.ServiceRegistrar, srv GalaxyRepositoryServer) {
|
||||||
|
// If the following call panics, it indicates UnimplementedGalaxyRepositoryServer was
|
||||||
|
// embedded by pointer and is nil. This will cause panics if an
|
||||||
|
// unimplemented method is ever invoked, so we test this at initialization
|
||||||
|
// time to prevent it from happening at runtime later due to I/O.
|
||||||
|
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||||
|
t.testEmbeddedByValue()
|
||||||
|
}
|
||||||
|
s.RegisterService(&GalaxyRepository_ServiceDesc, srv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _GalaxyRepository_TestConnection_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(TestConnectionRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(GalaxyRepositoryServer).TestConnection(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: GalaxyRepository_TestConnection_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(GalaxyRepositoryServer).TestConnection(ctx, req.(*TestConnectionRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _GalaxyRepository_GetLastDeployTime_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(GetLastDeployTimeRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(GalaxyRepositoryServer).GetLastDeployTime(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: GalaxyRepository_GetLastDeployTime_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(GalaxyRepositoryServer).GetLastDeployTime(ctx, req.(*GetLastDeployTimeRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _GalaxyRepository_DiscoverHierarchy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(DiscoverHierarchyRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(GalaxyRepositoryServer).DiscoverHierarchy(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: GalaxyRepository_DiscoverHierarchy_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(GalaxyRepositoryServer).DiscoverHierarchy(ctx, req.(*DiscoverHierarchyRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _GalaxyRepository_WatchDeployEvents_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||||
|
m := new(WatchDeployEventsRequest)
|
||||||
|
if err := stream.RecvMsg(m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return srv.(GalaxyRepositoryServer).WatchDeployEvents(m, &grpc.GenericServerStream[WatchDeployEventsRequest, DeployEvent]{ServerStream: stream})
|
||||||
|
}
|
||||||
|
|
||||||
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
|
type GalaxyRepository_WatchDeployEventsServer = grpc.ServerStreamingServer[DeployEvent]
|
||||||
|
|
||||||
|
// GalaxyRepository_ServiceDesc is the grpc.ServiceDesc for GalaxyRepository service.
|
||||||
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
|
// and not to be introspected or modified (even as a copy)
|
||||||
|
var GalaxyRepository_ServiceDesc = grpc.ServiceDesc{
|
||||||
|
ServiceName: "galaxy_repository.v1.GalaxyRepository",
|
||||||
|
HandlerType: (*GalaxyRepositoryServer)(nil),
|
||||||
|
Methods: []grpc.MethodDesc{
|
||||||
|
{
|
||||||
|
MethodName: "TestConnection",
|
||||||
|
Handler: _GalaxyRepository_TestConnection_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "GetLastDeployTime",
|
||||||
|
Handler: _GalaxyRepository_GetLastDeployTime_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "DiscoverHierarchy",
|
||||||
|
Handler: _GalaxyRepository_DiscoverHierarchy_Handler,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Streams: []grpc.StreamDesc{
|
||||||
|
{
|
||||||
|
StreamName: "WatchDeployEvents",
|
||||||
|
Handler: _GalaxyRepository_WatchDeployEvents_Handler,
|
||||||
|
ServerStreams: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Metadata: "galaxy_repository.proto",
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,332 @@
|
|||||||
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// - protoc-gen-go-grpc v1.6.1
|
||||||
|
// - protoc v7.34.1
|
||||||
|
// source: mxaccess_gateway.proto
|
||||||
|
|
||||||
|
package generated
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
grpc "google.golang.org/grpc"
|
||||||
|
codes "google.golang.org/grpc/codes"
|
||||||
|
status "google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This is a compile-time assertion to ensure that this generated file
|
||||||
|
// is compatible with the grpc package it is being compiled against.
|
||||||
|
// Requires gRPC-Go v1.64.0 or later.
|
||||||
|
const _ = grpc.SupportPackageIsVersion9
|
||||||
|
|
||||||
|
const (
|
||||||
|
MxAccessGateway_OpenSession_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/OpenSession"
|
||||||
|
MxAccessGateway_CloseSession_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/CloseSession"
|
||||||
|
MxAccessGateway_Invoke_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/Invoke"
|
||||||
|
MxAccessGateway_StreamEvents_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/StreamEvents"
|
||||||
|
MxAccessGateway_AcknowledgeAlarm_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/AcknowledgeAlarm"
|
||||||
|
MxAccessGateway_StreamAlarms_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/StreamAlarms"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MxAccessGatewayClient is the client API for MxAccessGateway service.
|
||||||
|
//
|
||||||
|
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||||
|
//
|
||||||
|
// Public client API for MXAccess sessions hosted by the gateway.
|
||||||
|
type MxAccessGatewayClient interface {
|
||||||
|
OpenSession(ctx context.Context, in *OpenSessionRequest, opts ...grpc.CallOption) (*OpenSessionReply, error)
|
||||||
|
CloseSession(ctx context.Context, in *CloseSessionRequest, opts ...grpc.CallOption) (*CloseSessionReply, error)
|
||||||
|
Invoke(ctx context.Context, in *MxCommandRequest, opts ...grpc.CallOption) (*MxCommandReply, error)
|
||||||
|
StreamEvents(ctx context.Context, in *StreamEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[MxEvent], error)
|
||||||
|
AcknowledgeAlarm(ctx context.Context, in *AcknowledgeAlarmRequest, opts ...grpc.CallOption) (*AcknowledgeAlarmReply, error)
|
||||||
|
// Session-less central alarm feed. The stream opens with the current
|
||||||
|
// active-alarm snapshot (one `active_alarm` per alarm), then a single
|
||||||
|
// `snapshot_complete`, then a `transition` for every subsequent change.
|
||||||
|
// Served by the gateway's always-on alarm monitor; any number of clients
|
||||||
|
// fan out from the single monitor without opening a worker session.
|
||||||
|
StreamAlarms(ctx context.Context, in *StreamAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[AlarmFeedMessage], error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type mxAccessGatewayClient struct {
|
||||||
|
cc grpc.ClientConnInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMxAccessGatewayClient(cc grpc.ClientConnInterface) MxAccessGatewayClient {
|
||||||
|
return &mxAccessGatewayClient{cc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *mxAccessGatewayClient) OpenSession(ctx context.Context, in *OpenSessionRequest, opts ...grpc.CallOption) (*OpenSessionReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(OpenSessionReply)
|
||||||
|
err := c.cc.Invoke(ctx, MxAccessGateway_OpenSession_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *mxAccessGatewayClient) CloseSession(ctx context.Context, in *CloseSessionRequest, opts ...grpc.CallOption) (*CloseSessionReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(CloseSessionReply)
|
||||||
|
err := c.cc.Invoke(ctx, MxAccessGateway_CloseSession_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *mxAccessGatewayClient) Invoke(ctx context.Context, in *MxCommandRequest, opts ...grpc.CallOption) (*MxCommandReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(MxCommandReply)
|
||||||
|
err := c.cc.Invoke(ctx, MxAccessGateway_Invoke_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *mxAccessGatewayClient) StreamEvents(ctx context.Context, in *StreamEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[MxEvent], error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
stream, err := c.cc.NewStream(ctx, &MxAccessGateway_ServiceDesc.Streams[0], MxAccessGateway_StreamEvents_FullMethodName, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
x := &grpc.GenericClientStream[StreamEventsRequest, MxEvent]{ClientStream: stream}
|
||||||
|
if err := x.ClientStream.SendMsg(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := x.ClientStream.CloseSend(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return x, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
|
type MxAccessGateway_StreamEventsClient = grpc.ServerStreamingClient[MxEvent]
|
||||||
|
|
||||||
|
func (c *mxAccessGatewayClient) AcknowledgeAlarm(ctx context.Context, in *AcknowledgeAlarmRequest, opts ...grpc.CallOption) (*AcknowledgeAlarmReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(AcknowledgeAlarmReply)
|
||||||
|
err := c.cc.Invoke(ctx, MxAccessGateway_AcknowledgeAlarm_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *mxAccessGatewayClient) StreamAlarms(ctx context.Context, in *StreamAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[AlarmFeedMessage], error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
stream, err := c.cc.NewStream(ctx, &MxAccessGateway_ServiceDesc.Streams[1], MxAccessGateway_StreamAlarms_FullMethodName, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
x := &grpc.GenericClientStream[StreamAlarmsRequest, AlarmFeedMessage]{ClientStream: stream}
|
||||||
|
if err := x.ClientStream.SendMsg(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := x.ClientStream.CloseSend(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return x, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
|
type MxAccessGateway_StreamAlarmsClient = grpc.ServerStreamingClient[AlarmFeedMessage]
|
||||||
|
|
||||||
|
// MxAccessGatewayServer is the server API for MxAccessGateway service.
|
||||||
|
// All implementations must embed UnimplementedMxAccessGatewayServer
|
||||||
|
// for forward compatibility.
|
||||||
|
//
|
||||||
|
// Public client API for MXAccess sessions hosted by the gateway.
|
||||||
|
type MxAccessGatewayServer interface {
|
||||||
|
OpenSession(context.Context, *OpenSessionRequest) (*OpenSessionReply, error)
|
||||||
|
CloseSession(context.Context, *CloseSessionRequest) (*CloseSessionReply, error)
|
||||||
|
Invoke(context.Context, *MxCommandRequest) (*MxCommandReply, error)
|
||||||
|
StreamEvents(*StreamEventsRequest, grpc.ServerStreamingServer[MxEvent]) error
|
||||||
|
AcknowledgeAlarm(context.Context, *AcknowledgeAlarmRequest) (*AcknowledgeAlarmReply, error)
|
||||||
|
// Session-less central alarm feed. The stream opens with the current
|
||||||
|
// active-alarm snapshot (one `active_alarm` per alarm), then a single
|
||||||
|
// `snapshot_complete`, then a `transition` for every subsequent change.
|
||||||
|
// Served by the gateway's always-on alarm monitor; any number of clients
|
||||||
|
// fan out from the single monitor without opening a worker session.
|
||||||
|
StreamAlarms(*StreamAlarmsRequest, grpc.ServerStreamingServer[AlarmFeedMessage]) error
|
||||||
|
mustEmbedUnimplementedMxAccessGatewayServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnimplementedMxAccessGatewayServer must be embedded to have
|
||||||
|
// forward compatible implementations.
|
||||||
|
//
|
||||||
|
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||||
|
// pointer dereference when methods are called.
|
||||||
|
type UnimplementedMxAccessGatewayServer struct{}
|
||||||
|
|
||||||
|
func (UnimplementedMxAccessGatewayServer) OpenSession(context.Context, *OpenSessionRequest) (*OpenSessionReply, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method OpenSession not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedMxAccessGatewayServer) CloseSession(context.Context, *CloseSessionRequest) (*CloseSessionReply, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method CloseSession not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedMxAccessGatewayServer) Invoke(context.Context, *MxCommandRequest) (*MxCommandReply, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method Invoke not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedMxAccessGatewayServer) StreamEvents(*StreamEventsRequest, grpc.ServerStreamingServer[MxEvent]) error {
|
||||||
|
return status.Error(codes.Unimplemented, "method StreamEvents not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedMxAccessGatewayServer) AcknowledgeAlarm(context.Context, *AcknowledgeAlarmRequest) (*AcknowledgeAlarmReply, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method AcknowledgeAlarm not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedMxAccessGatewayServer) StreamAlarms(*StreamAlarmsRequest, grpc.ServerStreamingServer[AlarmFeedMessage]) error {
|
||||||
|
return status.Error(codes.Unimplemented, "method StreamAlarms not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedMxAccessGatewayServer) mustEmbedUnimplementedMxAccessGatewayServer() {}
|
||||||
|
func (UnimplementedMxAccessGatewayServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
|
// UnsafeMxAccessGatewayServer may be embedded to opt out of forward compatibility for this service.
|
||||||
|
// Use of this interface is not recommended, as added methods to MxAccessGatewayServer will
|
||||||
|
// result in compilation errors.
|
||||||
|
type UnsafeMxAccessGatewayServer interface {
|
||||||
|
mustEmbedUnimplementedMxAccessGatewayServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterMxAccessGatewayServer(s grpc.ServiceRegistrar, srv MxAccessGatewayServer) {
|
||||||
|
// If the following call panics, it indicates UnimplementedMxAccessGatewayServer was
|
||||||
|
// embedded by pointer and is nil. This will cause panics if an
|
||||||
|
// unimplemented method is ever invoked, so we test this at initialization
|
||||||
|
// time to prevent it from happening at runtime later due to I/O.
|
||||||
|
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||||
|
t.testEmbeddedByValue()
|
||||||
|
}
|
||||||
|
s.RegisterService(&MxAccessGateway_ServiceDesc, srv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _MxAccessGateway_OpenSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(OpenSessionRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(MxAccessGatewayServer).OpenSession(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: MxAccessGateway_OpenSession_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(MxAccessGatewayServer).OpenSession(ctx, req.(*OpenSessionRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _MxAccessGateway_CloseSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(CloseSessionRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(MxAccessGatewayServer).CloseSession(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: MxAccessGateway_CloseSession_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(MxAccessGatewayServer).CloseSession(ctx, req.(*CloseSessionRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _MxAccessGateway_Invoke_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(MxCommandRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(MxAccessGatewayServer).Invoke(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: MxAccessGateway_Invoke_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(MxAccessGatewayServer).Invoke(ctx, req.(*MxCommandRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _MxAccessGateway_StreamEvents_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||||
|
m := new(StreamEventsRequest)
|
||||||
|
if err := stream.RecvMsg(m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return srv.(MxAccessGatewayServer).StreamEvents(m, &grpc.GenericServerStream[StreamEventsRequest, MxEvent]{ServerStream: stream})
|
||||||
|
}
|
||||||
|
|
||||||
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
|
type MxAccessGateway_StreamEventsServer = grpc.ServerStreamingServer[MxEvent]
|
||||||
|
|
||||||
|
func _MxAccessGateway_AcknowledgeAlarm_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(AcknowledgeAlarmRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(MxAccessGatewayServer).AcknowledgeAlarm(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: MxAccessGateway_AcknowledgeAlarm_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(MxAccessGatewayServer).AcknowledgeAlarm(ctx, req.(*AcknowledgeAlarmRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _MxAccessGateway_StreamAlarms_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||||
|
m := new(StreamAlarmsRequest)
|
||||||
|
if err := stream.RecvMsg(m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return srv.(MxAccessGatewayServer).StreamAlarms(m, &grpc.GenericServerStream[StreamAlarmsRequest, AlarmFeedMessage]{ServerStream: stream})
|
||||||
|
}
|
||||||
|
|
||||||
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
|
type MxAccessGateway_StreamAlarmsServer = grpc.ServerStreamingServer[AlarmFeedMessage]
|
||||||
|
|
||||||
|
// MxAccessGateway_ServiceDesc is the grpc.ServiceDesc for MxAccessGateway service.
|
||||||
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
|
// and not to be introspected or modified (even as a copy)
|
||||||
|
var MxAccessGateway_ServiceDesc = grpc.ServiceDesc{
|
||||||
|
ServiceName: "mxaccess_gateway.v1.MxAccessGateway",
|
||||||
|
HandlerType: (*MxAccessGatewayServer)(nil),
|
||||||
|
Methods: []grpc.MethodDesc{
|
||||||
|
{
|
||||||
|
MethodName: "OpenSession",
|
||||||
|
Handler: _MxAccessGateway_OpenSession_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "CloseSession",
|
||||||
|
Handler: _MxAccessGateway_CloseSession_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "Invoke",
|
||||||
|
Handler: _MxAccessGateway_Invoke_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "AcknowledgeAlarm",
|
||||||
|
Handler: _MxAccessGateway_AcknowledgeAlarm_Handler,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Streams: []grpc.StreamDesc{
|
||||||
|
{
|
||||||
|
StreamName: "StreamEvents",
|
||||||
|
Handler: _MxAccessGateway_StreamEvents_Handler,
|
||||||
|
ServerStreams: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
StreamName: "StreamAlarms",
|
||||||
|
Handler: _MxAccessGateway_StreamAlarms_Handler,
|
||||||
|
ServerStreams: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Metadata: "mxaccess_gateway.proto",
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AcknowledgeAlarm acknowledges an active MXAccess alarm condition through the
|
||||||
|
// gateway. The gateway authenticates the request against the API key's
|
||||||
|
// invoke:alarm-ack scope and forwards the acknowledge to the worker's MXAccess
|
||||||
|
// session; the resulting native MxStatus is returned in the reply.
|
||||||
|
//
|
||||||
|
// Acks are idempotent — re-acking an already-acked condition is a no-op at
|
||||||
|
// the MxAccess layer.
|
||||||
|
func (c *Client) AcknowledgeAlarm(ctx context.Context, req *AcknowledgeAlarmRequest) (*AcknowledgeAlarmReply, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, errors.New("mxgateway: acknowledge alarm request is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
callCtx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
reply, err := c.raw.AcknowledgeAlarm(callCtx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &GatewayError{Op: "acknowledge alarm", Err: err}
|
||||||
|
}
|
||||||
|
if err := EnsureProtocolSuccess("acknowledge alarm", reply.GetProtocolStatus(), nil); err != nil {
|
||||||
|
return reply, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamAlarms attaches to the gateway's central alarm feed. The stream opens
|
||||||
|
// with one AlarmFeedMessage per currently-active alarm (the ConditionRefresh
|
||||||
|
// snapshot), then a single snapshot-complete sentinel, then a transition for
|
||||||
|
// every subsequent raise / acknowledge / clear. It is served by the gateway's
|
||||||
|
// always-on alarm monitor — no worker session is opened — so any number of
|
||||||
|
// clients may attach.
|
||||||
|
//
|
||||||
|
// The returned stream is owned by the caller; cancel ctx to release it.
|
||||||
|
// Optional alarm-reference prefix scoping (req.AlarmFilterPrefix) limits the
|
||||||
|
// stream to a sub-tree.
|
||||||
|
func (c *Client) StreamAlarms(ctx context.Context, req *StreamAlarmsRequest) (StreamAlarmsClient, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, errors.New("mxgateway: stream alarms request is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
stream, err := c.raw.StreamAlarms(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &GatewayError{Op: "stream alarms", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stream, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/grpc/test/bufconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pins the Go SDK surface for the alarm RPCs: AcknowledgeAlarm + StreamAlarms.
|
||||||
|
|
||||||
|
func TestAcknowledgeAlarmSendsRequestAndReturnsReply(t *testing.T) {
|
||||||
|
fake := &fakeGatewayWithAlarms{
|
||||||
|
acknowledgeReply: &pb.AcknowledgeAlarmReply{
|
||||||
|
CorrelationId: "corr-1",
|
||||||
|
ProtocolStatus: &pb.ProtocolStatus{
|
||||||
|
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||||
|
},
|
||||||
|
Status: &pb.MxStatusProxy{
|
||||||
|
Success: 1,
|
||||||
|
Category: pb.MxStatusCategory_MX_STATUS_CATEGORY_OK,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
reply, err := client.AcknowledgeAlarm(context.Background(), &pb.AcknowledgeAlarmRequest{
|
||||||
|
ClientCorrelationId: "corr-1",
|
||||||
|
AlarmFullReference: "Tank01.Level.HiHi",
|
||||||
|
Comment: "investigating",
|
||||||
|
OperatorUser: "alice",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AcknowledgeAlarm() error = %v", err)
|
||||||
|
}
|
||||||
|
if reply.GetProtocolStatus().GetCode() != pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK {
|
||||||
|
t.Fatalf("protocol status = %v", reply.GetProtocolStatus().GetCode())
|
||||||
|
}
|
||||||
|
if got := fake.acknowledgeRequest.GetAlarmFullReference(); got != "Tank01.Level.HiHi" {
|
||||||
|
t.Fatalf("captured alarm reference = %q", got)
|
||||||
|
}
|
||||||
|
if got := fake.acknowledgeRequest.GetComment(); got != "investigating" {
|
||||||
|
t.Fatalf("captured comment = %q", got)
|
||||||
|
}
|
||||||
|
if got := fake.acknowledgeAuth; got != "Bearer test-api-key" {
|
||||||
|
t.Fatalf("authorization metadata = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAcknowledgeAlarmRejectsNilRequest(t *testing.T) {
|
||||||
|
fake := &fakeGatewayWithAlarms{}
|
||||||
|
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
_, err := client.AcknowledgeAlarm(context.Background(), nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("AcknowledgeAlarm(nil) returned no error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAcknowledgeAlarmMapsUnauthenticated(t *testing.T) {
|
||||||
|
fake := &fakeGatewayWithAlarms{
|
||||||
|
acknowledgeError: status.Error(codes.Unauthenticated, "expired key"),
|
||||||
|
}
|
||||||
|
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
_, err := client.AcknowledgeAlarm(context.Background(), &pb.AcknowledgeAlarmRequest{
|
||||||
|
AlarmFullReference: "Tank01.Level.HiHi",
|
||||||
|
OperatorUser: "alice",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("AcknowledgeAlarm() returned no error on Unauthenticated")
|
||||||
|
}
|
||||||
|
var gwErr *GatewayError
|
||||||
|
if !errors.As(err, &gwErr) {
|
||||||
|
t.Fatalf("error %T does not unwrap to *GatewayError", err)
|
||||||
|
}
|
||||||
|
if got, _ := status.FromError(gwErr.Err); got.Code() != codes.Unauthenticated {
|
||||||
|
t.Fatalf("inner status code = %v", got.Code())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamAlarmsStreamsSnapshotThenSnapshotComplete(t *testing.T) {
|
||||||
|
fake := &fakeGatewayWithAlarms{
|
||||||
|
activeSnapshots: []*pb.ActiveAlarmSnapshot{
|
||||||
|
{
|
||||||
|
AlarmFullReference: "Tank01.Level.HiHi",
|
||||||
|
CurrentState: pb.AlarmConditionState_ALARM_CONDITION_STATE_ACTIVE,
|
||||||
|
Severity: 750,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AlarmFullReference: "Tank02.Level.HiHi",
|
||||||
|
CurrentState: pb.AlarmConditionState_ALARM_CONDITION_STATE_ACTIVE_ACKED,
|
||||||
|
Severity: 750,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
stream, err := client.StreamAlarms(context.Background(), &pb.StreamAlarmsRequest{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StreamAlarms() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var received []*pb.AlarmFeedMessage
|
||||||
|
for {
|
||||||
|
msg, err := stream.Recv()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stream.Recv() error = %v", err)
|
||||||
|
}
|
||||||
|
received = append(received, msg)
|
||||||
|
}
|
||||||
|
if len(received) != 3 {
|
||||||
|
t.Fatalf("message count = %d, want 3", len(received))
|
||||||
|
}
|
||||||
|
if received[0].GetActiveAlarm().GetAlarmFullReference() != "Tank01.Level.HiHi" {
|
||||||
|
t.Fatalf("message[0] ref = %q", received[0].GetActiveAlarm().GetAlarmFullReference())
|
||||||
|
}
|
||||||
|
if received[1].GetActiveAlarm().GetCurrentState() != pb.AlarmConditionState_ALARM_CONDITION_STATE_ACTIVE_ACKED {
|
||||||
|
t.Fatalf("message[1] state = %v", received[1].GetActiveAlarm().GetCurrentState())
|
||||||
|
}
|
||||||
|
if !received[2].GetSnapshotComplete() {
|
||||||
|
t.Fatalf("final message is not snapshot_complete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamAlarmsPassesFilterPrefix(t *testing.T) {
|
||||||
|
fake := &fakeGatewayWithAlarms{}
|
||||||
|
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
stream, err := client.StreamAlarms(context.Background(), &pb.StreamAlarmsRequest{
|
||||||
|
AlarmFilterPrefix: "Tank01.",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StreamAlarms() error = %v", err)
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
_, err := stream.Recv()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stream.Recv() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := fake.streamRequest.GetAlarmFilterPrefix(); got != "Tank01." {
|
||||||
|
t.Fatalf("captured filter prefix = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeGatewayWithAlarms struct {
|
||||||
|
pb.UnimplementedMxAccessGatewayServer
|
||||||
|
|
||||||
|
acknowledgeRequest *pb.AcknowledgeAlarmRequest
|
||||||
|
acknowledgeReply *pb.AcknowledgeAlarmReply
|
||||||
|
acknowledgeError error
|
||||||
|
acknowledgeAuth string
|
||||||
|
|
||||||
|
streamRequest *pb.StreamAlarmsRequest
|
||||||
|
activeSnapshots []*pb.ActiveAlarmSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeGatewayWithAlarms) AcknowledgeAlarm(ctx context.Context, req *pb.AcknowledgeAlarmRequest) (*pb.AcknowledgeAlarmReply, error) {
|
||||||
|
s.acknowledgeRequest = req
|
||||||
|
s.acknowledgeAuth = authorizationFromContext(ctx)
|
||||||
|
if s.acknowledgeError != nil {
|
||||||
|
return nil, s.acknowledgeError
|
||||||
|
}
|
||||||
|
if s.acknowledgeReply != nil {
|
||||||
|
return s.acknowledgeReply, nil
|
||||||
|
}
|
||||||
|
return &pb.AcknowledgeAlarmReply{
|
||||||
|
ProtocolStatus: &pb.ProtocolStatus{
|
||||||
|
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeGatewayWithAlarms) StreamAlarms(req *pb.StreamAlarmsRequest, stream grpc.ServerStreamingServer[pb.AlarmFeedMessage]) error {
|
||||||
|
s.streamRequest = req
|
||||||
|
for _, snap := range s.activeSnapshots {
|
||||||
|
if err := stream.Send(&pb.AlarmFeedMessage{
|
||||||
|
Payload: &pb.AlarmFeedMessage_ActiveAlarm{ActiveAlarm: snap},
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stream.Send(&pb.AlarmFeedMessage{
|
||||||
|
Payload: &pb.AlarmFeedMessage_SnapshotComplete{SnapshotComplete: true},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBufconnClientWithAlarms(t *testing.T, fake *fakeGatewayWithAlarms) (*Client, func()) {
|
||||||
|
t.Helper()
|
||||||
|
listener := bufconn.Listen(bufSize)
|
||||||
|
server := grpc.NewServer()
|
||||||
|
pb.RegisterMxAccessGatewayServer(server, fake)
|
||||||
|
go func() {
|
||||||
|
_ = server.Serve(listener)
|
||||||
|
}()
|
||||||
|
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
|
||||||
|
return listener.DialContext(ctx)
|
||||||
|
}
|
||||||
|
// grpc.NewClient defaults to the dns scheme; use passthrough so the
|
||||||
|
// bufconn fake target reaches the context dialer unresolved.
|
||||||
|
client, err := Dial(context.Background(), Options{
|
||||||
|
Endpoint: "passthrough:///bufnet",
|
||||||
|
APIKey: "test-api-key",
|
||||||
|
Plaintext: true,
|
||||||
|
DialOptions: []grpc.DialOption{grpc.WithContextDialer(dialer)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Dial() error = %v", err)
|
||||||
|
}
|
||||||
|
return client, func() {
|
||||||
|
client.Close()
|
||||||
|
server.Stop()
|
||||||
|
listener.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
const authorizationHeader = "authorization"
|
||||||
|
|
||||||
|
func unaryAuthInterceptor(apiKey string) grpc.UnaryClientInterceptor {
|
||||||
|
return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
|
||||||
|
return invoker(authContext(ctx, apiKey), method, req, reply, cc, opts...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func streamAuthInterceptor(apiKey string) grpc.StreamClientInterceptor {
|
||||||
|
return func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
|
||||||
|
return streamer(authContext(ctx, apiKey), desc, cc, method, opts...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func authContext(ctx context.Context, apiKey string) context.Context {
|
||||||
|
if apiKey == "" {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata.AppendToOutgoingContext(ctx, authorizationHeader, "Bearer "+apiKey)
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
// Package mxgateway is the Go client for the MXAccess Gateway gRPC service.
|
||||||
|
//
|
||||||
|
// The package wraps the generated gRPC contract with session-oriented helpers
|
||||||
|
// for invoking MXAccess commands, streaming events, and browsing the Galaxy
|
||||||
|
// Repository. Authentication uses an API-key bearer token attached as gRPC
|
||||||
|
// metadata on every call.
|
||||||
|
//
|
||||||
|
// Typical use opens a Client with Dial, opens a Session, invokes commands such
|
||||||
|
// as Register, AddItem, Advise, and Write, and consumes events via
|
||||||
|
// SubscribeEvents. Galaxy Repository browse RPCs are exposed through
|
||||||
|
// GalaxyClient.
|
||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/connectivity"
|
||||||
|
"google.golang.org/grpc/credentials"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
"google.golang.org/protobuf/types/known/durationpb"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultDialTimeout = 10 * time.Second
|
||||||
|
defaultCallTimeout = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client owns a gateway gRPC connection and exposes session-oriented helpers.
|
||||||
|
type Client struct {
|
||||||
|
conn *grpc.ClientConn
|
||||||
|
raw pb.MxAccessGatewayClient
|
||||||
|
opts Options
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dial opens a gRPC connection to the gateway and configures auth metadata
|
||||||
|
// and transport security.
|
||||||
|
//
|
||||||
|
// The connection is created lazily with grpc.NewClient: the channel is not
|
||||||
|
// established until the first RPC (or the readiness probe below) needs it, so
|
||||||
|
// a gateway that is briefly unavailable at Dial time no longer turns into a
|
||||||
|
// hard error — the connection recovers when the gateway comes up. To preserve
|
||||||
|
// fail-fast behavior, Dial then runs an explicit readiness probe bounded by
|
||||||
|
// DialTimeout (default 10s, or ctx's deadline when sooner): it triggers the
|
||||||
|
// initial connect and waits for the channel to reach Ready, returning a
|
||||||
|
// *GatewayError if the gateway cannot be reached in that window. Cancelling
|
||||||
|
// ctx aborts the probe.
|
||||||
|
func Dial(ctx context.Context, opts Options) (*Client, error) {
|
||||||
|
conn, err := dial(ctx, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewClient(conn, opts), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dial builds the shared gRPC connection used by both Client and GalaxyClient:
|
||||||
|
// it resolves transport credentials, assembles dial options, creates a lazy
|
||||||
|
// connection with grpc.NewClient, and runs the DialTimeout-bounded readiness
|
||||||
|
// probe so callers still fail fast when the gateway is unreachable.
|
||||||
|
func dial(ctx context.Context, opts Options) (*grpc.ClientConn, error) {
|
||||||
|
if opts.Endpoint == "" {
|
||||||
|
return nil, errors.New("mxgateway: endpoint is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
transportCredentials, err := resolveTransportCredentials(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dialOptions := []grpc.DialOption{
|
||||||
|
grpc.WithTransportCredentials(transportCredentials),
|
||||||
|
grpc.WithUnaryInterceptor(unaryAuthInterceptor(opts.APIKey)),
|
||||||
|
grpc.WithStreamInterceptor(streamAuthInterceptor(opts.APIKey)),
|
||||||
|
}
|
||||||
|
dialOptions = append(dialOptions, opts.DialOptions...)
|
||||||
|
|
||||||
|
conn, err := grpc.NewClient(opts.Endpoint, dialOptions...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &GatewayError{Op: "dial", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := waitForReady(ctx, conn, opts.DialTimeout); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, &GatewayError{Op: "dial", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForReady triggers the initial connect on conn and blocks until the
|
||||||
|
// channel reaches connectivity.Ready, the timeout elapses, or ctx is
|
||||||
|
// cancelled. The wait is bounded by dialTimeout when positive, otherwise by
|
||||||
|
// ctx's existing deadline, otherwise by defaultDialTimeout.
|
||||||
|
func waitForReady(ctx context.Context, conn *grpc.ClientConn, dialTimeout time.Duration) error {
|
||||||
|
probeCtx := ctx
|
||||||
|
cancel := func() {}
|
||||||
|
if dialTimeout > 0 {
|
||||||
|
probeCtx, cancel = context.WithTimeout(ctx, dialTimeout)
|
||||||
|
} else if _, ok := ctx.Deadline(); !ok {
|
||||||
|
probeCtx, cancel = context.WithTimeout(ctx, defaultDialTimeout)
|
||||||
|
}
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
conn.Connect()
|
||||||
|
for {
|
||||||
|
state := conn.GetState()
|
||||||
|
if state == connectivity.Ready {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !conn.WaitForStateChange(probeCtx, state) {
|
||||||
|
return probeCtx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient wraps an existing gRPC connection. The caller owns closing conn
|
||||||
|
// unless it calls Close on the returned Client.
|
||||||
|
func NewClient(conn *grpc.ClientConn, opts Options) *Client {
|
||||||
|
return &Client{
|
||||||
|
conn: conn,
|
||||||
|
raw: pb.NewMxAccessGatewayClient(conn),
|
||||||
|
opts: opts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RawClient returns the generated gRPC client for command-specific parity tests.
|
||||||
|
func (c *Client) RawClient() RawGatewayClient {
|
||||||
|
return c.raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenSession creates a gateway-backed MXAccess session.
|
||||||
|
func (c *Client) OpenSession(ctx context.Context, opts OpenSessionOptions) (*Session, error) {
|
||||||
|
reply, err := c.OpenSessionRaw(ctx, opts.Request())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return newSession(c, reply), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenSessionRaw sends a raw OpenSession request and validates protocol status.
|
||||||
|
func (c *Client) OpenSessionRaw(ctx context.Context, req *OpenSessionRequest) (*OpenSessionReply, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, errors.New("mxgateway: open session request is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
callCtx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
reply, err := c.raw.OpenSession(callCtx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &GatewayError{Op: "open session", Err: err}
|
||||||
|
}
|
||||||
|
if err := EnsureProtocolSuccess("open session", reply.GetProtocolStatus(), nil); err != nil {
|
||||||
|
return reply, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoke sends a raw MXAccess command request and validates protocol and
|
||||||
|
// MXAccess status fields while preserving the raw reply on typed errors.
|
||||||
|
func (c *Client) Invoke(ctx context.Context, req *MxCommandRequest) (*MxCommandReply, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, errors.New("mxgateway: command request is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
callCtx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
reply, err := c.raw.Invoke(callCtx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &GatewayError{Op: "invoke", Err: err}
|
||||||
|
}
|
||||||
|
if err := EnsureProtocolSuccess("invoke", reply.GetProtocolStatus(), reply); err != nil {
|
||||||
|
return reply, err
|
||||||
|
}
|
||||||
|
if err := EnsureMxAccessSuccess("invoke", reply); err != nil {
|
||||||
|
return reply, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseSessionRaw sends a raw CloseSession request and validates protocol
|
||||||
|
// status.
|
||||||
|
func (c *Client) CloseSessionRaw(ctx context.Context, req *CloseSessionRequest) (*CloseSessionReply, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, errors.New("mxgateway: close session request is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
callCtx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
reply, err := c.raw.CloseSession(callCtx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &GatewayError{Op: "close session", Err: err}
|
||||||
|
}
|
||||||
|
if err := EnsureProtocolSuccess("close session", reply.GetProtocolStatus(), nil); err != nil {
|
||||||
|
return reply, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamEventsRaw starts the generated event stream for callers that need direct
|
||||||
|
// control over Recv.
|
||||||
|
func (c *Client) StreamEventsRaw(ctx context.Context, req *StreamEventsRequest) (RawEventStream, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, errors.New("mxgateway: stream events request is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
stream, err := c.raw.StreamEvents(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &GatewayError{Op: "stream events", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stream, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the underlying gRPC connection.
|
||||||
|
func (c *Client) Close() error {
|
||||||
|
if c == nil || c.conn == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||||
|
return callContext(ctx, c.opts.CallTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// callContext derives a per-RPC context from ctx, applying callTimeout: zero
|
||||||
|
// uses defaultCallTimeout, a negative value disables the bound entirely, and a
|
||||||
|
// caller-supplied deadline that is already sooner than the derived timeout is
|
||||||
|
// kept as-is rather than being lengthened.
|
||||||
|
func callContext(ctx context.Context, callTimeout time.Duration) (context.Context, context.CancelFunc) {
|
||||||
|
timeout := callTimeout
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = defaultCallTimeout
|
||||||
|
}
|
||||||
|
if timeout < 0 {
|
||||||
|
return ctx, func() {}
|
||||||
|
}
|
||||||
|
if deadline, ok := ctx.Deadline(); ok {
|
||||||
|
timeoutDeadline := time.Now().Add(timeout)
|
||||||
|
if deadline.Before(timeoutDeadline) {
|
||||||
|
return ctx, func() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return context.WithTimeout(ctx, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveTransportCredentials(opts Options) (credentials.TransportCredentials, error) {
|
||||||
|
if opts.TransportCredentials != nil {
|
||||||
|
return opts.TransportCredentials, nil
|
||||||
|
}
|
||||||
|
if opts.Plaintext {
|
||||||
|
return insecure.NewCredentials(), nil
|
||||||
|
}
|
||||||
|
if opts.CACertFile != "" {
|
||||||
|
return credentials.NewClientTLSFromFile(opts.CACertFile, opts.ServerNameOverride)
|
||||||
|
}
|
||||||
|
if opts.TLSConfig != nil {
|
||||||
|
cfg := opts.TLSConfig.Clone()
|
||||||
|
if opts.ServerNameOverride != "" {
|
||||||
|
cfg.ServerName = opts.ServerNameOverride
|
||||||
|
}
|
||||||
|
return credentials.NewTLS(cfg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return credentials.NewTLS(&tls.Config{
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
ServerName: opts.ServerNameOverride,
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenSessionOptions describes fields used to create an OpenSessionRequest.
|
||||||
|
type OpenSessionOptions struct {
|
||||||
|
// RequestedBackend selects the gateway worker backend (empty for default).
|
||||||
|
RequestedBackend string
|
||||||
|
// ClientSessionName is a human-readable name recorded on the session.
|
||||||
|
ClientSessionName string
|
||||||
|
// ClientCorrelationID echoes through gateway logs and replies for tracing.
|
||||||
|
ClientCorrelationID string
|
||||||
|
// CommandTimeout sets the per-command timeout the gateway forwards to the
|
||||||
|
// worker; zero leaves the gateway default in place.
|
||||||
|
CommandTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request returns the raw protobuf OpenSessionRequest for these options.
|
||||||
|
func (o OpenSessionOptions) Request() *OpenSessionRequest {
|
||||||
|
req := &OpenSessionRequest{
|
||||||
|
RequestedBackend: o.RequestedBackend,
|
||||||
|
ClientSessionName: o.ClientSessionName,
|
||||||
|
ClientCorrelationId: o.ClientCorrelationID,
|
||||||
|
}
|
||||||
|
if o.CommandTimeout > 0 {
|
||||||
|
req.CommandTimeout = durationpb.New(o.CommandTimeout)
|
||||||
|
}
|
||||||
|
return req
|
||||||
|
}
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
"google.golang.org/grpc/test/bufconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
const bufSize = 1024 * 1024
|
||||||
|
|
||||||
|
func TestDialAttachesAuthMetadataToUnaryCalls(t *testing.T) {
|
||||||
|
fake := &fakeGatewayServer{
|
||||||
|
openReply: &pb.OpenSessionReply{
|
||||||
|
SessionId: "session-1",
|
||||||
|
GatewayProtocolVersion: GatewayProtocolVersion,
|
||||||
|
WorkerProtocolVersion: WorkerProtocolVersion,
|
||||||
|
ProtocolStatus: &pb.ProtocolStatus{
|
||||||
|
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, cleanup := newBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
_, err := client.OpenSession(context.Background(), OpenSessionOptions{ClientSessionName: "fixture"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenSession() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := fake.openAuth; got != "Bearer test-api-key" {
|
||||||
|
t.Fatalf("authorization metadata = %q, want %q", got, "Bearer test-api-key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamEventsAttachesAuthMetadataAndClosesOnCancellation(t *testing.T) {
|
||||||
|
fake := &fakeGatewayServer{
|
||||||
|
streamStarted: make(chan struct{}),
|
||||||
|
}
|
||||||
|
client, cleanup := newBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
session := NewSessionForID(client, "session-1")
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
events, err := session.Events(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Events() error = %v", err)
|
||||||
|
}
|
||||||
|
<-fake.streamStarted
|
||||||
|
|
||||||
|
first := <-events
|
||||||
|
if first.Err != nil {
|
||||||
|
t.Fatalf("first event error = %v", first.Err)
|
||||||
|
}
|
||||||
|
if first.Event.GetWorkerSequence() != 1 {
|
||||||
|
t.Fatalf("worker sequence = %d, want 1", first.Event.GetWorkerSequence())
|
||||||
|
}
|
||||||
|
if got := fake.streamAuth; got != "Bearer test-api-key" {
|
||||||
|
t.Fatalf("stream authorization metadata = %q, want %q", got, "Bearer test-api-key")
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case _, ok := <-events:
|
||||||
|
if ok {
|
||||||
|
t.Fatal("events channel produced an extra item after cancellation")
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("events channel did not close after cancellation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventSubscriptionCloseStopsStream(t *testing.T) {
|
||||||
|
fake := &fakeGatewayServer{
|
||||||
|
streamStarted: make(chan struct{}),
|
||||||
|
streamDone: make(chan struct{}),
|
||||||
|
}
|
||||||
|
client, cleanup := newBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
session := NewSessionForID(client, "session-1")
|
||||||
|
|
||||||
|
subscription, err := session.SubscribeEvents(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SubscribeEvents() error = %v", err)
|
||||||
|
}
|
||||||
|
<-fake.streamStarted
|
||||||
|
first := <-subscription.Events()
|
||||||
|
if first.Err != nil {
|
||||||
|
t.Fatalf("first event error = %v", first.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
subscription.Close()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-fake.streamDone:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("event stream did not stop after subscription close")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case _, ok := <-subscription.Events():
|
||||||
|
if ok {
|
||||||
|
t.Fatal("subscription channel remained open after close")
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("subscription channel did not close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventsAfterCancelsStreamWhenCompatibilityChannelIsAbandoned(t *testing.T) {
|
||||||
|
fake := &fakeGatewayServer{
|
||||||
|
streamStarted: make(chan struct{}),
|
||||||
|
streamDone: make(chan struct{}),
|
||||||
|
streamEventCount: 256,
|
||||||
|
}
|
||||||
|
client, cleanup := newBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
session := NewSessionForID(client, "session-1")
|
||||||
|
|
||||||
|
events, err := session.EventsAfter(context.Background(), 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EventsAfter() error = %v", err)
|
||||||
|
}
|
||||||
|
<-fake.streamStarted
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-fake.streamDone:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("compatibility event stream did not stop after result channel filled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A slow consumer that abandons the buffer must still receive an explicit
|
||||||
|
// terminal overflow error before the channel closes, so it can tell
|
||||||
|
// "events dropped" apart from "stream ended normally".
|
||||||
|
var sawOverflow bool
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case result, ok := <-events:
|
||||||
|
if !ok {
|
||||||
|
if !sawOverflow {
|
||||||
|
t.Fatal("compatibility event channel closed without an ErrEventBufferOverflow result")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if result.Err != nil {
|
||||||
|
if !errors.Is(result.Err, ErrEventBufferOverflow) {
|
||||||
|
t.Fatalf("terminal result error = %v, want ErrEventBufferOverflow", result.Err)
|
||||||
|
}
|
||||||
|
sawOverflow = true
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("compatibility event channel did not close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionHelpersBuildCommandsAndExposeRawReply(t *testing.T) {
|
||||||
|
fake := &fakeGatewayServer{
|
||||||
|
invokeReply: &pb.MxCommandReply{
|
||||||
|
SessionId: "session-1",
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM2,
|
||||||
|
ProtocolStatus: &pb.ProtocolStatus{
|
||||||
|
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||||
|
},
|
||||||
|
Payload: &pb.MxCommandReply_AddItem2{
|
||||||
|
AddItem2: &pb.AddItem2Reply{ItemHandle: 42},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, cleanup := newBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
session := NewSessionForID(client, "session-1")
|
||||||
|
|
||||||
|
itemHandle, err := session.AddItem2(context.Background(), 12, "Area001.Pump001.Speed", "runtime")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddItem2() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if itemHandle != 42 {
|
||||||
|
t.Fatalf("item handle = %d, want 42", itemHandle)
|
||||||
|
}
|
||||||
|
req := fake.invokeRequest
|
||||||
|
if req.GetSessionId() != "session-1" {
|
||||||
|
t.Fatalf("session id = %q, want session-1", req.GetSessionId())
|
||||||
|
}
|
||||||
|
if req.GetClientCorrelationId() == "" {
|
||||||
|
t.Fatal("client correlation id is empty")
|
||||||
|
}
|
||||||
|
if req.GetCommand().GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM2 {
|
||||||
|
t.Fatalf("command kind = %s", req.GetCommand().GetKind())
|
||||||
|
}
|
||||||
|
if req.GetCommand().GetAddItem2().GetItemContext() != "runtime" {
|
||||||
|
t.Fatalf("item context = %q, want runtime", req.GetCommand().GetAddItem2().GetItemContext())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubscribeBulkBuildsOneBulkCommandAndReturnsResults(t *testing.T) {
|
||||||
|
fake := &fakeGatewayServer{
|
||||||
|
invokeReply: &pb.MxCommandReply{
|
||||||
|
SessionId: "session-1",
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_SUBSCRIBE_BULK,
|
||||||
|
ProtocolStatus: &pb.ProtocolStatus{
|
||||||
|
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||||
|
},
|
||||||
|
Payload: &pb.MxCommandReply_SubscribeBulk{
|
||||||
|
SubscribeBulk: &pb.BulkSubscribeReply{
|
||||||
|
Results: []*pb.SubscribeResult{
|
||||||
|
{
|
||||||
|
ServerHandle: 12,
|
||||||
|
TagAddress: "Area001.Pump001.Speed",
|
||||||
|
ItemHandle: 34,
|
||||||
|
WasSuccessful: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, cleanup := newBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
session := NewSessionForID(client, "session-1")
|
||||||
|
|
||||||
|
results, err := session.SubscribeBulk(context.Background(), 12, []string{"Area001.Pump001.Speed"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SubscribeBulk() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) != 1 || results[0].GetItemHandle() != 34 {
|
||||||
|
t.Fatalf("results = %#v, want item handle 34", results)
|
||||||
|
}
|
||||||
|
req := fake.invokeRequest
|
||||||
|
if req.GetCommand().GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_SUBSCRIBE_BULK {
|
||||||
|
t.Fatalf("command kind = %s", req.GetCommand().GetKind())
|
||||||
|
}
|
||||||
|
if got := req.GetCommand().GetSubscribeBulk().GetTagAddresses(); len(got) != 1 || got[0] != "Area001.Pump001.Speed" {
|
||||||
|
t.Fatalf("tag addresses = %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteBulkBuildsOneBulkCommandAndReturnsPerEntryResults(t *testing.T) {
|
||||||
|
fake := &fakeGatewayServer{
|
||||||
|
invokeReply: &pb.MxCommandReply{
|
||||||
|
SessionId: "session-1",
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_BULK,
|
||||||
|
ProtocolStatus: &pb.ProtocolStatus{
|
||||||
|
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||||
|
},
|
||||||
|
Payload: &pb.MxCommandReply_WriteBulk{
|
||||||
|
WriteBulk: &pb.BulkWriteReply{
|
||||||
|
Results: []*pb.BulkWriteResult{
|
||||||
|
{ServerHandle: 12, ItemHandle: 901, WasSuccessful: true},
|
||||||
|
{ServerHandle: 12, ItemHandle: 902, WasSuccessful: false, ErrorMessage: "invalid handle"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, cleanup := newBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
session := NewSessionForID(client, "session-1")
|
||||||
|
|
||||||
|
results, err := session.WriteBulk(context.Background(), 12, []*pb.WriteBulkEntry{
|
||||||
|
{ItemHandle: 901, UserId: 5, Value: &pb.MxValue{DataType: pb.MxDataType_MX_DATA_TYPE_INTEGER, Kind: &pb.MxValue_Int32Value{Int32Value: 11}}},
|
||||||
|
{ItemHandle: 902, UserId: 5, Value: &pb.MxValue{DataType: pb.MxDataType_MX_DATA_TYPE_INTEGER, Kind: &pb.MxValue_Int32Value{Int32Value: 22}}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WriteBulk() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 2 || !results[0].GetWasSuccessful() || results[1].GetWasSuccessful() {
|
||||||
|
t.Fatalf("results = %#v, want [success, failure]", results)
|
||||||
|
}
|
||||||
|
req := fake.invokeRequest
|
||||||
|
if req.GetCommand().GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_WRITE_BULK {
|
||||||
|
t.Fatalf("command kind = %s", req.GetCommand().GetKind())
|
||||||
|
}
|
||||||
|
if got := req.GetCommand().GetWriteBulk().GetEntries(); len(got) != 2 {
|
||||||
|
t.Fatalf("entries = %#v, want 2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadBulkForwardsTimeoutAndUnpacksCachedFlag(t *testing.T) {
|
||||||
|
fake := &fakeGatewayServer{
|
||||||
|
invokeReply: &pb.MxCommandReply{
|
||||||
|
SessionId: "session-1",
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK,
|
||||||
|
ProtocolStatus: &pb.ProtocolStatus{
|
||||||
|
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||||
|
},
|
||||||
|
Payload: &pb.MxCommandReply_ReadBulk{
|
||||||
|
ReadBulk: &pb.BulkReadReply{
|
||||||
|
Results: []*pb.BulkReadResult{
|
||||||
|
{
|
||||||
|
ServerHandle: 12,
|
||||||
|
TagAddress: "Area001.Pump001.Speed",
|
||||||
|
ItemHandle: 34,
|
||||||
|
WasSuccessful: true,
|
||||||
|
WasCached: true,
|
||||||
|
Value: &pb.MxValue{DataType: pb.MxDataType_MX_DATA_TYPE_INTEGER, Kind: &pb.MxValue_Int32Value{Int32Value: 99}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, cleanup := newBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
session := NewSessionForID(client, "session-1")
|
||||||
|
|
||||||
|
results, err := session.ReadBulk(context.Background(), 12, []string{"Area001.Pump001.Speed"}, 750*time.Millisecond)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadBulk() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 1 || !results[0].GetWasCached() || results[0].GetValue().GetInt32Value() != 99 {
|
||||||
|
t.Fatalf("results = %#v", results)
|
||||||
|
}
|
||||||
|
if got := fake.invokeRequest.GetCommand().GetReadBulk().GetTimeoutMs(); got != 750 {
|
||||||
|
t.Fatalf("timeout_ms = %d, want 750", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvokeReturnsTypedMxAccessErrorWithRawReply(t *testing.T) {
|
||||||
|
hresult := int32(-2147467259)
|
||||||
|
fake := &fakeGatewayServer{
|
||||||
|
invokeReply: &pb.MxCommandReply{
|
||||||
|
SessionId: "session-1",
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE,
|
||||||
|
Hresult: &hresult,
|
||||||
|
DiagnosticMessage: "native failure",
|
||||||
|
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_MXACCESS_FAILURE, Message: "MXAccess failed"},
|
||||||
|
Statuses: []*pb.MxStatusProxy{{Success: 0, DiagnosticText: "failed"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, cleanup := newBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
session := NewSessionForID(client, "session-1")
|
||||||
|
|
||||||
|
err := session.Advise(context.Background(), 12, 34)
|
||||||
|
|
||||||
|
var mxErr *MxAccessError
|
||||||
|
if !errors.As(err, &mxErr) {
|
||||||
|
t.Fatalf("error %T does not support errors.As(*MxAccessError)", err)
|
||||||
|
}
|
||||||
|
if mxErr.Reply.GetHresult() != hresult {
|
||||||
|
t.Fatalf("raw reply HRESULT = %d, want %d", mxErr.Reply.GetHresult(), hresult)
|
||||||
|
}
|
||||||
|
var commandErr *CommandError
|
||||||
|
if !errors.As(err, &commandErr) {
|
||||||
|
t.Fatalf("error %T does not support errors.As(*CommandError)", err)
|
||||||
|
}
|
||||||
|
if commandErr.Reply.GetDiagnosticMessage() != "native failure" {
|
||||||
|
t.Fatalf("raw diagnostic = %q", commandErr.Reply.GetDiagnosticMessage())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBufconnClient(t *testing.T, fake *fakeGatewayServer) (*Client, func()) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
listener := bufconn.Listen(bufSize)
|
||||||
|
server := grpc.NewServer()
|
||||||
|
pb.RegisterMxAccessGatewayServer(server, fake)
|
||||||
|
go func() {
|
||||||
|
if err := server.Serve(listener); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
|
||||||
|
t.Errorf("bufconn server failed: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
|
||||||
|
return listener.DialContext(ctx)
|
||||||
|
}
|
||||||
|
// grpc.NewClient defaults the target scheme to dns; the bufconn fake name
|
||||||
|
// is not DNS-resolvable, so use the passthrough scheme to hand the target
|
||||||
|
// straight to the context dialer.
|
||||||
|
client, err := Dial(context.Background(), Options{
|
||||||
|
Endpoint: "passthrough:///bufnet",
|
||||||
|
APIKey: "test-api-key",
|
||||||
|
Plaintext: true,
|
||||||
|
DialOptions: []grpc.DialOption{
|
||||||
|
grpc.WithContextDialer(dialer),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Dial() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return client, func() {
|
||||||
|
client.Close()
|
||||||
|
server.Stop()
|
||||||
|
listener.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeGatewayServer struct {
|
||||||
|
pb.UnimplementedMxAccessGatewayServer
|
||||||
|
|
||||||
|
openReply *pb.OpenSessionReply
|
||||||
|
openAuth string
|
||||||
|
streamAuth string
|
||||||
|
streamStarted chan struct{}
|
||||||
|
streamDone chan struct{}
|
||||||
|
streamEventCount int
|
||||||
|
invokeReply *pb.MxCommandReply
|
||||||
|
invokeRequest *pb.MxCommandRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeGatewayServer) OpenSession(ctx context.Context, req *pb.OpenSessionRequest) (*pb.OpenSessionReply, error) {
|
||||||
|
s.openAuth = authorizationFromContext(ctx)
|
||||||
|
if s.openReply != nil {
|
||||||
|
return s.openReply, nil
|
||||||
|
}
|
||||||
|
return &pb.OpenSessionReply{
|
||||||
|
SessionId: "session-1",
|
||||||
|
ProtocolStatus: &pb.ProtocolStatus{
|
||||||
|
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeGatewayServer) CloseSession(ctx context.Context, req *pb.CloseSessionRequest) (*pb.CloseSessionReply, error) {
|
||||||
|
return &pb.CloseSessionReply{
|
||||||
|
SessionId: req.GetSessionId(),
|
||||||
|
ProtocolStatus: &pb.ProtocolStatus{
|
||||||
|
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeGatewayServer) Invoke(ctx context.Context, req *pb.MxCommandRequest) (*pb.MxCommandReply, error) {
|
||||||
|
s.invokeRequest = req
|
||||||
|
if s.invokeReply != nil {
|
||||||
|
return s.invokeReply, nil
|
||||||
|
}
|
||||||
|
return &pb.MxCommandReply{
|
||||||
|
SessionId: req.GetSessionId(),
|
||||||
|
Kind: req.GetCommand().GetKind(),
|
||||||
|
ProtocolStatus: &pb.ProtocolStatus{
|
||||||
|
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeGatewayServer) StreamEvents(req *pb.StreamEventsRequest, stream grpc.ServerStreamingServer[pb.MxEvent]) error {
|
||||||
|
s.streamAuth = authorizationFromContext(stream.Context())
|
||||||
|
if s.streamDone != nil {
|
||||||
|
defer close(s.streamDone)
|
||||||
|
}
|
||||||
|
if s.streamStarted != nil {
|
||||||
|
close(s.streamStarted)
|
||||||
|
}
|
||||||
|
eventCount := s.streamEventCount
|
||||||
|
if eventCount == 0 {
|
||||||
|
eventCount = 1
|
||||||
|
}
|
||||||
|
for sequence := 1; sequence <= eventCount; sequence++ {
|
||||||
|
if err := stream.Send(&pb.MxEvent{
|
||||||
|
SessionId: req.GetSessionId(),
|
||||||
|
Family: pb.MxEventFamily_MX_EVENT_FAMILY_ON_DATA_CHANGE,
|
||||||
|
WorkerSequence: uint64(sequence),
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<-stream.Context().Done()
|
||||||
|
return io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorizationFromContext(ctx context.Context) string {
|
||||||
|
md, ok := metadata.FromIncomingContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
values := md.Get(authorizationHeader)
|
||||||
|
if len(values) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return values[0]
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValueConversionFixtures(t *testing.T) {
|
||||||
|
data, err := os.ReadFile(filepath.Join("..", "..", "proto", "fixtures", "behavior", "values", "value-conversion-cases.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var fixture struct {
|
||||||
|
Cases []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ExpectedKind string `json:"expectedKind"`
|
||||||
|
Value json.RawMessage `json:"value"`
|
||||||
|
} `json:"cases"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &fixture); err != nil {
|
||||||
|
t.Fatalf("parse fixture manifest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range fixture.Cases {
|
||||||
|
t.Run(tc.ID, func(t *testing.T) {
|
||||||
|
var value pb.MxValue
|
||||||
|
if err := protojson.Unmarshal(tc.Value, &value); err != nil {
|
||||||
|
t.Fatalf("parse value: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := NativeValue(&value); err != nil {
|
||||||
|
t.Fatalf("NativeValue() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := value.ProtoReflect().WhichOneof(value.ProtoReflect().Descriptor().Oneofs().ByName("kind")).JSONName(); got != tc.ExpectedKind {
|
||||||
|
t.Fatalf("kind = %q, want %q", got, tc.ExpectedKind)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusConversionFixtures(t *testing.T) {
|
||||||
|
data, err := os.ReadFile(filepath.Join("..", "..", "proto", "fixtures", "behavior", "statuses", "status-conversion-cases.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var fixture struct {
|
||||||
|
Cases []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status json.RawMessage `json:"status"`
|
||||||
|
} `json:"cases"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &fixture); err != nil {
|
||||||
|
t.Fatalf("parse fixture manifest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range fixture.Cases {
|
||||||
|
t.Run(tc.ID, func(t *testing.T) {
|
||||||
|
var status pb.MxStatusProxy
|
||||||
|
if err := protojson.Unmarshal(tc.Status, &status); err != nil {
|
||||||
|
t.Fatalf("parse status: %v", err)
|
||||||
|
}
|
||||||
|
if got, want := StatusSucceeded(&status), status.GetSuccess() != 0; got != want {
|
||||||
|
t.Fatalf("StatusSucceeded() = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Client.Go-008: resolveTransportCredentials precedence -----------------
|
||||||
|
|
||||||
|
// TestResolveTransportCredentialsPrecedence covers every branch of
|
||||||
|
// resolveTransportCredentials, which previously only had the Plaintext path
|
||||||
|
// exercised.
|
||||||
|
func TestResolveTransportCredentialsPrecedence(t *testing.T) {
|
||||||
|
custom := insecure.NewCredentials()
|
||||||
|
|
||||||
|
t.Run("TransportCredentialsWins", func(t *testing.T) {
|
||||||
|
creds, err := resolveTransportCredentials(Options{
|
||||||
|
TransportCredentials: custom,
|
||||||
|
Plaintext: true, // must be ignored
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if creds != custom {
|
||||||
|
t.Fatal("expected the explicit TransportCredentials to be returned as-is")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Plaintext", func(t *testing.T) {
|
||||||
|
creds, err := resolveTransportCredentials(Options{Plaintext: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got := creds.Info().SecurityProtocol; got != "insecure" {
|
||||||
|
t.Fatalf("expected insecure credentials, got security protocol %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CACertFileMissingErrors", func(t *testing.T) {
|
||||||
|
_, err := resolveTransportCredentials(Options{CACertFile: "does-not-exist.pem"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error for a missing CA cert file")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("TLSConfigWithServerNameOverride", func(t *testing.T) {
|
||||||
|
creds, err := resolveTransportCredentials(Options{
|
||||||
|
TLSConfig: &tls.Config{MinVersion: tls.VersionTLS13},
|
||||||
|
ServerNameOverride: "gateway.internal",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got := creds.Info().ServerName; got != "gateway.internal" {
|
||||||
|
t.Fatalf("expected ServerName override to be applied, got %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("DefaultTLSFloor", func(t *testing.T) {
|
||||||
|
creds, err := resolveTransportCredentials(Options{ServerNameOverride: "host"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got := creds.Info().SecurityProtocol; got != "tls" {
|
||||||
|
t.Fatalf("expected the default TLS credentials, got %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolveTransportCredentialsDoesNotMutateTLSConfig confirms the supplied
|
||||||
|
// TLSConfig is cloned, not mutated, when ServerNameOverride is applied.
|
||||||
|
func TestResolveTransportCredentialsDoesNotMutateTLSConfig(t *testing.T) {
|
||||||
|
cfg := &tls.Config{MinVersion: tls.VersionTLS12}
|
||||||
|
if _, err := resolveTransportCredentials(Options{
|
||||||
|
TLSConfig: cfg,
|
||||||
|
ServerNameOverride: "override",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.ServerName != "" {
|
||||||
|
t.Fatalf("resolveTransportCredentials mutated the caller's TLSConfig (ServerName=%q)", cfg.ServerName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Client.Go-008: callContext deadline arithmetic ------------------------
|
||||||
|
|
||||||
|
// TestCallContextDeadlineArithmetic covers the shared callContext deadline
|
||||||
|
// logic, including the negative-timeout disable case and the
|
||||||
|
// caller-deadline-is-sooner case.
|
||||||
|
func TestCallContextDeadlineArithmetic(t *testing.T) {
|
||||||
|
t.Run("ZeroUsesDefault", func(t *testing.T) {
|
||||||
|
ctx, cancel := callContext(context.Background(), 0)
|
||||||
|
defer cancel()
|
||||||
|
deadline, ok := ctx.Deadline()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected a deadline for the default timeout")
|
||||||
|
}
|
||||||
|
remaining := time.Until(deadline)
|
||||||
|
if remaining <= 0 || remaining > defaultCallTimeout+time.Second {
|
||||||
|
t.Fatalf("default deadline out of range: %v", remaining)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("NegativeDisablesBound", func(t *testing.T) {
|
||||||
|
base := context.Background()
|
||||||
|
ctx, cancel := callContext(base, -1)
|
||||||
|
defer cancel()
|
||||||
|
if _, ok := ctx.Deadline(); ok {
|
||||||
|
t.Fatal("a negative timeout must disable the deadline entirely")
|
||||||
|
}
|
||||||
|
if ctx != base {
|
||||||
|
t.Fatal("a negative timeout must return the caller context unchanged")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("PositiveAppliesTimeout", func(t *testing.T) {
|
||||||
|
ctx, cancel := callContext(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
deadline, ok := ctx.Deadline()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected a deadline")
|
||||||
|
}
|
||||||
|
remaining := time.Until(deadline)
|
||||||
|
if remaining <= 0 || remaining > 5*time.Second+time.Second {
|
||||||
|
t.Fatalf("deadline out of range: %v", remaining)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CallerDeadlineSoonerIsKept", func(t *testing.T) {
|
||||||
|
base, baseCancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer baseCancel()
|
||||||
|
ctx, cancel := callContext(base, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if ctx != base {
|
||||||
|
t.Fatal("a caller deadline sooner than the timeout must be kept as-is")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CallerDeadlineLaterIsShortened", func(t *testing.T) {
|
||||||
|
base, baseCancel := context.WithTimeout(context.Background(), time.Hour)
|
||||||
|
defer baseCancel()
|
||||||
|
ctx, cancel := callContext(base, time.Second)
|
||||||
|
defer cancel()
|
||||||
|
deadline, ok := ctx.Deadline()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected a deadline")
|
||||||
|
}
|
||||||
|
if remaining := time.Until(deadline); remaining > 2*time.Second {
|
||||||
|
t.Fatalf("expected the shorter timeout to win, got %v remaining", remaining)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Client.Go-008: NativeValue / NativeArray edge branches ----------------
|
||||||
|
|
||||||
|
// TestNativeValueEdgeKinds covers the array, raw-bytes, null, and
|
||||||
|
// nil-input branches of NativeValue.
|
||||||
|
func TestNativeValueEdgeKinds(t *testing.T) {
|
||||||
|
t.Run("NilInput", func(t *testing.T) {
|
||||||
|
got, err := NativeValue(nil)
|
||||||
|
if err != nil || got != nil {
|
||||||
|
t.Fatalf("NativeValue(nil) = (%v, %v), want (nil, nil)", got, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ExplicitNull", func(t *testing.T) {
|
||||||
|
got, err := NativeValue(&pb.MxValue{IsNull: true})
|
||||||
|
if err != nil || got != nil {
|
||||||
|
t.Fatalf("NativeValue(null) = (%v, %v), want (nil, nil)", got, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RawBytes", func(t *testing.T) {
|
||||||
|
raw := []byte{0x01, 0x02, 0x03}
|
||||||
|
got, err := NativeValue(&pb.MxValue{Kind: &pb.MxValue_RawValue{RawValue: raw}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
gotBytes, ok := got.([]byte)
|
||||||
|
if !ok || !reflect.DeepEqual(gotBytes, raw) {
|
||||||
|
t.Fatalf("NativeValue raw = %v, want %v", got, raw)
|
||||||
|
}
|
||||||
|
// The result must be a copy, not aliasing the protobuf field.
|
||||||
|
gotBytes[0] = 0xFF
|
||||||
|
if raw[0] != 0x01 {
|
||||||
|
t.Fatal("NativeValue raw result aliases the protobuf backing array")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ArrayValue", func(t *testing.T) {
|
||||||
|
value := &pb.MxValue{Kind: &pb.MxValue_ArrayValue{
|
||||||
|
ArrayValue: &pb.MxArray{Values: &pb.MxArray_Int32Values{
|
||||||
|
Int32Values: &pb.Int32Array{Values: []int32{7, 8}},
|
||||||
|
}},
|
||||||
|
}}
|
||||||
|
got, err := NativeValue(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, []int32{7, 8}) {
|
||||||
|
t.Fatalf("NativeValue array = %v, want [7 8]", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNativeArrayEdgeKinds covers the nil, raw-bytes, timestamp-with-nil, and
|
||||||
|
// unsupported-kind branches of NativeArray.
|
||||||
|
func TestNativeArrayEdgeKinds(t *testing.T) {
|
||||||
|
t.Run("NilInput", func(t *testing.T) {
|
||||||
|
got, err := NativeArray(nil)
|
||||||
|
if err != nil || got != nil {
|
||||||
|
t.Fatalf("NativeArray(nil) = (%v, %v), want (nil, nil)", got, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RawValues", func(t *testing.T) {
|
||||||
|
got, err := NativeArray(&pb.MxArray{Values: &pb.MxArray_RawValues{
|
||||||
|
RawValues: &pb.RawArray{Values: [][]byte{{0x0A}, {0x0B}}},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
want := [][]byte{{0x0A}, {0x0B}}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("NativeArray raw = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("TimestampWithNilEntry", func(t *testing.T) {
|
||||||
|
got, err := NativeArray(&pb.MxArray{Values: &pb.MxArray_TimestampValues{
|
||||||
|
TimestampValues: &pb.TimestampArray{Values: []*timestamppb.Timestamp{nil}},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
times, ok := got.([]time.Time)
|
||||||
|
if !ok || len(times) != 1 || !times[0].IsZero() {
|
||||||
|
t.Fatalf("NativeArray timestamp-with-nil = %v, want [zero-time]", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("UnsupportedKind", func(t *testing.T) {
|
||||||
|
// An MxArray with no oneof set hits the default branch.
|
||||||
|
_, err := NativeArray(&pb.MxArray{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error for an MxArray with no values set")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "unsupported array value kind") {
|
||||||
|
t.Fatalf("unexpected error text: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNativeValueUnsupportedKind covers the default branch of NativeValue.
|
||||||
|
func TestNativeValueUnsupportedKind(t *testing.T) {
|
||||||
|
// An MxValue with no oneof Kind set and IsNull false hits the default.
|
||||||
|
_, err := NativeValue(&pb.MxValue{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error for an MxValue with no kind set")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "unsupported value kind") {
|
||||||
|
t.Fatalf("unexpected error text: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Client.Go-005: dial migration -----------------------------------------
|
||||||
|
|
||||||
|
// TestDialFailsFastWhenGatewayUnreachable confirms that after the migration to
|
||||||
|
// grpc.NewClient the DialTimeout-bounded readiness probe still fails fast (and
|
||||||
|
// wraps the failure in *GatewayError) when the gateway cannot be reached.
|
||||||
|
func TestDialFailsFastWhenGatewayUnreachable(t *testing.T) {
|
||||||
|
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
|
||||||
|
return nil, errors.New("connection refused")
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
client, err := Dial(context.Background(), Options{
|
||||||
|
Endpoint: "passthrough:///unreachable",
|
||||||
|
APIKey: "k",
|
||||||
|
Plaintext: true,
|
||||||
|
DialTimeout: 500 * time.Millisecond,
|
||||||
|
DialOptions: []grpc.DialOption{grpc.WithContextDialer(dialer)},
|
||||||
|
})
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
if err == nil {
|
||||||
|
client.Close()
|
||||||
|
t.Fatal("expected Dial to fail for an unreachable gateway")
|
||||||
|
}
|
||||||
|
var gwErr *GatewayError
|
||||||
|
if !errors.As(err, &gwErr) || gwErr.Op != "dial" {
|
||||||
|
t.Fatalf("expected a *GatewayError with Op=dial, got %#v", err)
|
||||||
|
}
|
||||||
|
if elapsed > 5*time.Second {
|
||||||
|
t.Fatalf("Dial did not honor DialTimeout: took %v", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDialReadinessProbeReachesReady confirms the readiness probe succeeds
|
||||||
|
// against a live (bufconn) gateway, i.e. the lazy grpc.NewClient connection is
|
||||||
|
// driven to Ready before Dial returns.
|
||||||
|
func TestDialReadinessProbeReachesReady(t *testing.T) {
|
||||||
|
client, cleanup := newBufconnClient(t, &fakeGatewayServer{
|
||||||
|
openReply: &pb.OpenSessionReply{},
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
if client == nil {
|
||||||
|
t.Fatal("expected a connected client")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Client.Go-006: error taxonomy ----------------------------------------
|
||||||
|
|
||||||
|
// TestGatewayErrorCode confirms GatewayError.Code surfaces the wrapped gRPC
|
||||||
|
// status code without the caller unwrapping it.
|
||||||
|
func TestGatewayErrorCode(t *testing.T) {
|
||||||
|
var nilErr *GatewayError
|
||||||
|
if got := nilErr.Code(); got != codes.OK {
|
||||||
|
t.Fatalf("nil GatewayError.Code() = %v, want OK", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
gwErr := &GatewayError{Op: "invoke", Err: status.Error(codes.Unavailable, "down")}
|
||||||
|
if got := gwErr.Code(); got != codes.Unavailable {
|
||||||
|
t.Fatalf("GatewayError.Code() = %v, want Unavailable", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
plain := &GatewayError{Op: "dial", Err: errors.New("boom")}
|
||||||
|
if got := plain.Code(); got != codes.Unknown {
|
||||||
|
t.Fatalf("GatewayError.Code() for a non-status error = %v, want Unknown", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIsTransient verifies the transient/permanent classification including
|
||||||
|
// the unwrap-through-GatewayError path.
|
||||||
|
func TestIsTransient(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "nil", err: nil, want: false},
|
||||||
|
{name: "unavailable wrapped", err: &GatewayError{Op: "invoke", Err: status.Error(codes.Unavailable, "x")}, want: true},
|
||||||
|
{name: "deadline wrapped", err: &GatewayError{Op: "invoke", Err: status.Error(codes.DeadlineExceeded, "x")}, want: true},
|
||||||
|
{name: "resource exhausted", err: &GatewayError{Err: status.Error(codes.ResourceExhausted, "x")}, want: true},
|
||||||
|
{name: "unauthenticated permanent", err: &GatewayError{Err: status.Error(codes.Unauthenticated, "x")}, want: false},
|
||||||
|
{name: "invalid argument permanent", err: &GatewayError{Err: status.Error(codes.InvalidArgument, "x")}, want: false},
|
||||||
|
{name: "bare status unavailable", err: status.Error(codes.Unavailable, "x"), want: true},
|
||||||
|
{name: "plain error", err: errors.New("nope"), want: false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := IsTransient(tt.err); got != tt.want {
|
||||||
|
t.Fatalf("IsTransient(%v) = %v, want %v", tt.err, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Client.Go-007: correlation id fallback --------------------------------
|
||||||
|
|
||||||
|
// TestNewCorrelationIDUsesRandEntropy confirms the happy path yields a
|
||||||
|
// 32-hex-character id.
|
||||||
|
func TestNewCorrelationIDUsesRandEntropy(t *testing.T) {
|
||||||
|
id := newCorrelationID()
|
||||||
|
if len(id) != 32 {
|
||||||
|
t.Fatalf("expected a 32-char hex id, got %q (len %d)", id, len(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewCorrelationIDFallsBackOnRandFailure reproduces Client.Go-007: when
|
||||||
|
// crypto/rand fails, newCorrelationID must not return an empty string but a
|
||||||
|
// unique, non-empty fallback id so the command stays traceable.
|
||||||
|
func TestNewCorrelationIDFallsBackOnRandFailure(t *testing.T) {
|
||||||
|
original := randRead
|
||||||
|
randRead = func([]byte) (int, error) { return 0, errors.New("entropy unavailable") }
|
||||||
|
defer func() { randRead = original }()
|
||||||
|
|
||||||
|
first := newCorrelationID()
|
||||||
|
second := newCorrelationID()
|
||||||
|
|
||||||
|
if first == "" || second == "" {
|
||||||
|
t.Fatal("newCorrelationID returned an empty id on rand failure")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(first, "fallback-") {
|
||||||
|
t.Fatalf("expected a fallback- prefixed id, got %q", first)
|
||||||
|
}
|
||||||
|
if first == second {
|
||||||
|
t.Fatalf("fallback correlation ids must be unique, got %q twice", first)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrEventBufferOverflow is the terminal error delivered on the compatibility
|
||||||
|
// event channel returned by Session.Events / Session.EventsAfter when a slow
|
||||||
|
// consumer lets the bounded result buffer fill. It signals that the stream was
|
||||||
|
// cancelled and events were dropped, so a consumer can tell an overflow apart
|
||||||
|
// from a normal end-of-stream. Use Session.SubscribeEvents to block instead of
|
||||||
|
// dropping.
|
||||||
|
var ErrEventBufferOverflow = errors.New("mxgateway: event buffer overflow; compatibility stream cancelled and events dropped")
|
||||||
|
|
||||||
|
// GatewayError wraps transport-level gRPC failures.
|
||||||
|
type GatewayError struct {
|
||||||
|
// Op names the operation that failed (for example "dial" or "invoke").
|
||||||
|
Op string
|
||||||
|
// Err is the underlying gRPC or transport error.
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error returns the formatted gateway error message.
|
||||||
|
func (e *GatewayError) Error() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if e.Op == "" {
|
||||||
|
return fmt.Sprintf("mxgateway: %v", e.Err)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("mxgateway: %s failed: %v", e.Op, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap returns the wrapped transport error.
|
||||||
|
func (e *GatewayError) Unwrap() error {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code returns the gRPC status code of the wrapped transport error. It returns
|
||||||
|
// codes.OK when the error is nil and codes.Unknown when the wrapped error does
|
||||||
|
// not carry a gRPC status. Callers can use it to write retry, timeout, and
|
||||||
|
// auth handling without manually unwrapping and re-parsing the error.
|
||||||
|
func (e *GatewayError) Code() codes.Code {
|
||||||
|
if e == nil || e.Err == nil {
|
||||||
|
return codes.OK
|
||||||
|
}
|
||||||
|
return status.Code(e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTransient reports whether err is a transport failure that may succeed on
|
||||||
|
// retry — for example a gateway that is briefly Unavailable or a call that
|
||||||
|
// hit a DeadlineExceeded. Permanent failures (Unauthenticated, PermissionDenied,
|
||||||
|
// InvalidArgument, NotFound, and similar) return false. It unwraps through
|
||||||
|
// *GatewayError and any other error chain carrying a gRPC status, so callers
|
||||||
|
// do not need to call status.Code themselves.
|
||||||
|
func IsTransient(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch transientCode(err) {
|
||||||
|
case codes.Unavailable, codes.DeadlineExceeded, codes.ResourceExhausted, codes.Aborted:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// transientCode extracts a gRPC status code from err, preferring a wrapped
|
||||||
|
// *GatewayError's Code and otherwise falling back to status.Code on the chain.
|
||||||
|
func transientCode(err error) codes.Code {
|
||||||
|
var gatewayErr *GatewayError
|
||||||
|
if errors.As(err, &gatewayErr) {
|
||||||
|
return gatewayErr.Code()
|
||||||
|
}
|
||||||
|
return status.Code(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommandError reports a non-OK gateway protocol status and keeps the raw
|
||||||
|
// command reply when one exists.
|
||||||
|
type CommandError struct {
|
||||||
|
// Op names the gateway operation that produced the non-OK status.
|
||||||
|
Op string
|
||||||
|
// Status carries the gateway-reported protocol status.
|
||||||
|
Status *ProtocolStatus
|
||||||
|
// Reply is the raw command reply, when one was returned alongside the status.
|
||||||
|
Reply *MxCommandReply
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error returns the formatted command error message.
|
||||||
|
func (e *CommandError) Error() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
status := e.Status
|
||||||
|
if status == nil {
|
||||||
|
return fmt.Sprintf("mxgateway: %s failed with missing protocol status", e.Op)
|
||||||
|
}
|
||||||
|
if status.GetMessage() == "" {
|
||||||
|
return fmt.Sprintf("mxgateway: %s failed with protocol status %s", e.Op, status.GetCode())
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("mxgateway: %s failed with protocol status %s: %s", e.Op, status.GetCode(), status.GetMessage())
|
||||||
|
}
|
||||||
|
|
||||||
|
// MxAccessError reports HRESULT or MXSTATUS_PROXY failures returned by MXAccess.
|
||||||
|
type MxAccessError struct {
|
||||||
|
// Command is the wrapped CommandError when the protocol status carried one.
|
||||||
|
Command *CommandError
|
||||||
|
// Reply is the raw MXAccess command reply that surfaced the failure.
|
||||||
|
Reply *MxCommandReply
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error returns the formatted MXAccess error message.
|
||||||
|
func (e *MxAccessError) Error() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if e.Command != nil && e.Command.Status != nil && e.Command.Status.GetMessage() != "" {
|
||||||
|
return e.Command.Error()
|
||||||
|
}
|
||||||
|
if e.Reply != nil && e.Reply.GetDiagnosticMessage() != "" {
|
||||||
|
return fmt.Sprintf("mxgateway: MXAccess command %s failed: %s", e.Reply.GetKind(), e.Reply.GetDiagnosticMessage())
|
||||||
|
}
|
||||||
|
if e.Reply != nil && e.Reply.Hresult != nil {
|
||||||
|
return fmt.Sprintf("mxgateway: MXAccess command %s failed with HRESULT 0x%08X", e.Reply.GetKind(), uint32(e.Reply.GetHresult()))
|
||||||
|
}
|
||||||
|
return "mxgateway: MXAccess command failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap returns the wrapped CommandError, when one is present.
|
||||||
|
//
|
||||||
|
// When Command is nil (the HRESULT / MxStatusProxy path) it returns an
|
||||||
|
// untyped nil rather than a typed-nil *CommandError, so errors.As does not
|
||||||
|
// bind a nil pointer that a caller would then panic on.
|
||||||
|
func (e *MxAccessError) Unwrap() error {
|
||||||
|
if e == nil || e.Command == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.Command
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureProtocolSuccess returns a typed CommandError when status is non-OK.
|
||||||
|
func EnsureProtocolSuccess(op string, status *ProtocolStatus, reply *MxCommandReply) error {
|
||||||
|
if status == nil || status.GetCode() == pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
commandError := &CommandError{
|
||||||
|
Op: op,
|
||||||
|
Status: status,
|
||||||
|
Reply: reply,
|
||||||
|
}
|
||||||
|
if status.GetCode() == pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_MXACCESS_FAILURE {
|
||||||
|
return &MxAccessError{
|
||||||
|
Command: commandError,
|
||||||
|
Reply: reply,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return commandError
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureMxAccessSuccess returns a typed MxAccessError for failing HRESULTs or
|
||||||
|
// MXSTATUS_PROXY entries.
|
||||||
|
func EnsureMxAccessSuccess(op string, reply *MxCommandReply) error {
|
||||||
|
if reply == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if reply.Hresult != nil && reply.GetHresult() != 0 {
|
||||||
|
return &MxAccessError{Reply: reply}
|
||||||
|
}
|
||||||
|
for _, status := range reply.GetStatuses() {
|
||||||
|
if !StatusSucceeded(status) {
|
||||||
|
return &MxAccessError{Reply: reply}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMxAccessErrorUnwrapHResultPathNoTypedNilCommandError reproduces
|
||||||
|
// Client.Go-001: an MxAccessError built via the HRESULT / MxStatusProxy path
|
||||||
|
// leaves Command nil. Unwrap must not hand back a typed-nil *CommandError,
|
||||||
|
// because errors.As would then succeed while binding a nil pointer and a
|
||||||
|
// caller dereferencing it would panic.
|
||||||
|
func TestMxAccessErrorUnwrapHResultPathNoTypedNilCommandError(t *testing.T) {
|
||||||
|
hresult := int32(-2147467259) // 0x80004005, a failing HRESULT.
|
||||||
|
reply := &MxCommandReply{Hresult: &hresult}
|
||||||
|
|
||||||
|
err := EnsureMxAccessSuccess("invoke", reply)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected MxAccessError for a failing HRESULT, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
var ce *CommandError
|
||||||
|
if errors.As(err, &ce) {
|
||||||
|
t.Fatalf("errors.As bound *CommandError from an HRESULT-only MxAccessError (ce=%v); "+
|
||||||
|
"a caller dereferencing ce.Status would panic", ce)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMxAccessErrorUnwrapPopulatedCommand confirms the non-nil Command path
|
||||||
|
// still unwraps to the wrapped *CommandError.
|
||||||
|
func TestMxAccessErrorUnwrapPopulatedCommand(t *testing.T) {
|
||||||
|
command := &CommandError{Op: "invoke"}
|
||||||
|
err := &MxAccessError{Command: command}
|
||||||
|
|
||||||
|
var ce *CommandError
|
||||||
|
if !errors.As(err, &ce) {
|
||||||
|
t.Fatal("errors.As failed to bind the populated *CommandError")
|
||||||
|
}
|
||||||
|
if ce != command {
|
||||||
|
t.Fatalf("errors.As bound an unexpected *CommandError: got %v want %v", ce, command)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RawGalaxyRepositoryClient is the generated gRPC client interface for the
|
||||||
|
// Galaxy Repository service exposed for callers that need direct contract
|
||||||
|
// access.
|
||||||
|
type RawGalaxyRepositoryClient = pb.GalaxyRepositoryClient
|
||||||
|
|
||||||
|
// Generated protobuf aliases for Galaxy Repository messages.
|
||||||
|
type (
|
||||||
|
// TestConnectionRequest is the request for Galaxy Repository TestConnection.
|
||||||
|
TestConnectionRequest = pb.TestConnectionRequest
|
||||||
|
// TestConnectionReply is the reply for Galaxy Repository TestConnection.
|
||||||
|
TestConnectionReply = pb.TestConnectionReply
|
||||||
|
// GetLastDeployTimeRequest is the request for GetLastDeployTime.
|
||||||
|
GetLastDeployTimeRequest = pb.GetLastDeployTimeRequest
|
||||||
|
// GetLastDeployTimeReply is the reply for GetLastDeployTime.
|
||||||
|
GetLastDeployTimeReply = pb.GetLastDeployTimeReply
|
||||||
|
// DiscoverHierarchyRequest is the request for DiscoverHierarchy.
|
||||||
|
DiscoverHierarchyRequest = pb.DiscoverHierarchyRequest
|
||||||
|
// DiscoverHierarchyReply is the reply for DiscoverHierarchy.
|
||||||
|
DiscoverHierarchyReply = pb.DiscoverHierarchyReply
|
||||||
|
// GalaxyObject describes one Galaxy object with its dynamic attributes.
|
||||||
|
GalaxyObject = pb.GalaxyObject
|
||||||
|
// GalaxyAttribute describes one dynamic attribute on a GalaxyObject.
|
||||||
|
GalaxyAttribute = pb.GalaxyAttribute
|
||||||
|
// WatchDeployEventsRequest is the request for WatchDeployEvents.
|
||||||
|
WatchDeployEventsRequest = pb.WatchDeployEventsRequest
|
||||||
|
// DeployEvent is one Galaxy Repository deploy event.
|
||||||
|
DeployEvent = pb.DeployEvent
|
||||||
|
)
|
||||||
|
|
||||||
|
// RawDeployEventStream is the generated WatchDeployEvents client stream.
|
||||||
|
type RawDeployEventStream = grpc.ServerStreamingClient[pb.DeployEvent]
|
||||||
|
|
||||||
|
// GalaxyClient owns a gateway gRPC connection and exposes Galaxy Repository
|
||||||
|
// browse helpers. It mirrors the structure of Client and uses the same
|
||||||
|
// connection-management conventions.
|
||||||
|
type GalaxyClient struct {
|
||||||
|
conn *grpc.ClientConn
|
||||||
|
raw pb.GalaxyRepositoryClient
|
||||||
|
opts Options
|
||||||
|
}
|
||||||
|
|
||||||
|
// DialGalaxy opens a gRPC connection to the gateway for the Galaxy Repository
|
||||||
|
// service. It applies the same authentication metadata, transport security,
|
||||||
|
// lazy connection, and DialTimeout-bounded readiness probe as Dial.
|
||||||
|
func DialGalaxy(ctx context.Context, opts Options) (*GalaxyClient, error) {
|
||||||
|
conn, err := dial(ctx, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewGalaxyClient(conn, opts), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGalaxyClient wraps an existing gRPC connection for Galaxy Repository
|
||||||
|
// access. The caller owns closing conn unless it calls Close on the returned
|
||||||
|
// GalaxyClient.
|
||||||
|
func NewGalaxyClient(conn *grpc.ClientConn, opts Options) *GalaxyClient {
|
||||||
|
return &GalaxyClient{
|
||||||
|
conn: conn,
|
||||||
|
raw: pb.NewGalaxyRepositoryClient(conn),
|
||||||
|
opts: opts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RawClient returns the generated gRPC client for command-specific parity
|
||||||
|
// tests.
|
||||||
|
func (c *GalaxyClient) RawClient() RawGalaxyRepositoryClient {
|
||||||
|
return c.raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConnection probes the Galaxy Repository service. It returns the server's
|
||||||
|
// reported ok flag and a non-nil error only when the RPC itself fails.
|
||||||
|
func (c *GalaxyClient) TestConnection(ctx context.Context) (bool, error) {
|
||||||
|
callCtx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
reply, err := c.raw.TestConnection(callCtx, &pb.TestConnectionRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return false, &GatewayError{Op: "galaxy test connection", Err: err}
|
||||||
|
}
|
||||||
|
return reply.GetOk(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLastDeployTime returns the Galaxy's last deploy timestamp. When the server
|
||||||
|
// reports present=false (no deploy recorded yet) the call returns
|
||||||
|
// (time.Time{}, false, nil). When present=true the timestamp is returned in
|
||||||
|
// UTC with present=true.
|
||||||
|
func (c *GalaxyClient) GetLastDeployTime(ctx context.Context) (time.Time, bool, error) {
|
||||||
|
callCtx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
reply, err := c.raw.GetLastDeployTime(callCtx, &pb.GetLastDeployTimeRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, false, &GatewayError{Op: "galaxy get last deploy time", Err: err}
|
||||||
|
}
|
||||||
|
if !reply.GetPresent() {
|
||||||
|
return time.Time{}, false, nil
|
||||||
|
}
|
||||||
|
ts := reply.GetTimeOfLastDeploy()
|
||||||
|
if ts == nil {
|
||||||
|
return time.Time{}, false, nil
|
||||||
|
}
|
||||||
|
return ts.AsTime(), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiscoverHierarchy returns the deployed Galaxy object hierarchy with each
|
||||||
|
// object's dynamic attributes. The objects are returned in the order supplied
|
||||||
|
// by the server.
|
||||||
|
func (c *GalaxyClient) DiscoverHierarchy(ctx context.Context) ([]*GalaxyObject, error) {
|
||||||
|
callCtx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
reply, err := c.raw.DiscoverHierarchy(callCtx, &pb.DiscoverHierarchyRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, &GatewayError{Op: "galaxy discover hierarchy", Err: err}
|
||||||
|
}
|
||||||
|
return reply.GetObjects(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WatchDeployEventsRaw starts the generated WatchDeployEvents stream for callers
|
||||||
|
// that want direct control over Recv. The caller owns the returned stream's
|
||||||
|
// lifetime via ctx cancellation.
|
||||||
|
func (c *GalaxyClient) WatchDeployEventsRaw(ctx context.Context, req *WatchDeployEventsRequest) (RawDeployEventStream, error) {
|
||||||
|
if req == nil {
|
||||||
|
req = &pb.WatchDeployEventsRequest{}
|
||||||
|
}
|
||||||
|
|
||||||
|
stream, err := c.raw.WatchDeployEvents(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &GatewayError{Op: "galaxy watch deploy events", Err: err}
|
||||||
|
}
|
||||||
|
return stream, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WatchDeployEvents subscribes to Galaxy deploy events. The server emits a
|
||||||
|
// bootstrap event with the current state immediately on subscribe, then one
|
||||||
|
// event per new deploy. When lastSeenDeployTime is non-nil it is forwarded to
|
||||||
|
// the server to suppress the bootstrap event.
|
||||||
|
//
|
||||||
|
// The returned event channel is closed when the server completes the stream
|
||||||
|
// (io.EOF), when ctx is cancelled, or after a terminal error has been
|
||||||
|
// delivered on the error channel. The error channel is also closed once the
|
||||||
|
// stream tears down. Surfaced errors are wrapped in *GatewayError.
|
||||||
|
//
|
||||||
|
// Cancel ctx to tear the stream down cleanly.
|
||||||
|
func (c *GalaxyClient) WatchDeployEvents(
|
||||||
|
ctx context.Context,
|
||||||
|
lastSeenDeployTime *time.Time,
|
||||||
|
) (<-chan *DeployEvent, <-chan error, error) {
|
||||||
|
req := &pb.WatchDeployEventsRequest{}
|
||||||
|
if lastSeenDeployTime != nil {
|
||||||
|
req.LastSeenDeployTime = timestamppb.New(*lastSeenDeployTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
stream, err := c.WatchDeployEventsRaw(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
events := make(chan *DeployEvent, 16)
|
||||||
|
errs := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
defer close(events)
|
||||||
|
defer close(errs)
|
||||||
|
for {
|
||||||
|
event, recvErr := stream.Recv()
|
||||||
|
if recvErr == nil {
|
||||||
|
select {
|
||||||
|
case events <- event:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if errors.Is(recvErr, io.EOF) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if status.Code(recvErr) == codes.Canceled || ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case errs <- &GatewayError{Op: "galaxy watch deploy events", Err: recvErr}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return events, errs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the underlying gRPC connection.
|
||||||
|
func (c *GalaxyClient) Close() error {
|
||||||
|
if c == nil || c.conn == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GalaxyClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||||
|
return callContext(ctx, c.opts.CallTimeout)
|
||||||
|
}
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/test/bufconn"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGalaxyTestConnectionAttachesAuthAndReturnsOk(t *testing.T) {
|
||||||
|
fake := &fakeGalaxyServer{
|
||||||
|
testReply: &pb.TestConnectionReply{Ok: true},
|
||||||
|
}
|
||||||
|
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ok, err := client.TestConnection(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TestConnection() error = %v", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("TestConnection() ok = false, want true")
|
||||||
|
}
|
||||||
|
if got := fake.testAuth; got != "Bearer test-api-key" {
|
||||||
|
t.Fatalf("authorization metadata = %q, want %q", got, "Bearer test-api-key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGalaxyGetLastDeployTimeReturnsAbsentForPresentFalse(t *testing.T) {
|
||||||
|
fake := &fakeGalaxyServer{
|
||||||
|
deployReply: &pb.GetLastDeployTimeReply{Present: false},
|
||||||
|
}
|
||||||
|
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
got, present, err := client.GetLastDeployTime(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLastDeployTime() error = %v", err)
|
||||||
|
}
|
||||||
|
if present {
|
||||||
|
t.Fatalf("present = true, want false")
|
||||||
|
}
|
||||||
|
if !got.IsZero() {
|
||||||
|
t.Fatalf("time = %v, want zero", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGalaxyGetLastDeployTimeReturnsTimestampWhenPresent(t *testing.T) {
|
||||||
|
want := time.Date(2026, 4, 28, 12, 34, 56, 0, time.UTC)
|
||||||
|
fake := &fakeGalaxyServer{
|
||||||
|
deployReply: &pb.GetLastDeployTimeReply{
|
||||||
|
Present: true,
|
||||||
|
TimeOfLastDeploy: timestamppb.New(want),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
got, present, err := client.GetLastDeployTime(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLastDeployTime() error = %v", err)
|
||||||
|
}
|
||||||
|
if !present {
|
||||||
|
t.Fatalf("present = false, want true")
|
||||||
|
}
|
||||||
|
if !got.Equal(want) {
|
||||||
|
t.Fatalf("time = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGalaxyGetLastDeployTimeReturnsAbsentWhenTimestampNil(t *testing.T) {
|
||||||
|
fake := &fakeGalaxyServer{
|
||||||
|
deployReply: &pb.GetLastDeployTimeReply{Present: true, TimeOfLastDeploy: nil},
|
||||||
|
}
|
||||||
|
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
got, present, err := client.GetLastDeployTime(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLastDeployTime() error = %v", err)
|
||||||
|
}
|
||||||
|
if present {
|
||||||
|
t.Fatalf("present = true, want false (nil timestamp)")
|
||||||
|
}
|
||||||
|
if !got.IsZero() {
|
||||||
|
t.Fatalf("time = %v, want zero", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGalaxyDiscoverHierarchyReturnsObjects(t *testing.T) {
|
||||||
|
fake := &fakeGalaxyServer{
|
||||||
|
discoverReply: &pb.DiscoverHierarchyReply{
|
||||||
|
Objects: []*pb.GalaxyObject{
|
||||||
|
{
|
||||||
|
GobjectId: 1,
|
||||||
|
TagName: "TestMachine_001",
|
||||||
|
ContainedName: "TestMachine_001",
|
||||||
|
BrowseName: "TestMachine_001",
|
||||||
|
IsArea: false,
|
||||||
|
CategoryId: 7,
|
||||||
|
TemplateChain: []string{"$Object", "$AppObject"},
|
||||||
|
Attributes: []*pb.GalaxyAttribute{
|
||||||
|
{
|
||||||
|
AttributeName: "DownloadPath",
|
||||||
|
FullTagReference: "TestMachine_001.DownloadPath",
|
||||||
|
MxDataType: 8,
|
||||||
|
DataTypeName: "String",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
GobjectId: 2,
|
||||||
|
TagName: "TestMachine_002",
|
||||||
|
ContainedName: "TestMachine_002",
|
||||||
|
ParentGobjectId: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
objects, err := client.DiscoverHierarchy(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DiscoverHierarchy() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(objects) != 2 {
|
||||||
|
t.Fatalf("len(objects) = %d, want 2", len(objects))
|
||||||
|
}
|
||||||
|
if objects[0].GetTagName() != "TestMachine_001" {
|
||||||
|
t.Fatalf("objects[0].TagName = %q", objects[0].GetTagName())
|
||||||
|
}
|
||||||
|
if len(objects[0].GetAttributes()) != 1 {
|
||||||
|
t.Fatalf("len(attributes) = %d, want 1", len(objects[0].GetAttributes()))
|
||||||
|
}
|
||||||
|
if objects[0].GetAttributes()[0].GetFullTagReference() != "TestMachine_001.DownloadPath" {
|
||||||
|
t.Fatalf("FullTagReference = %q", objects[0].GetAttributes()[0].GetFullTagReference())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGalaxyDialReturnsGatewayErrorOnRpcFailure(t *testing.T) {
|
||||||
|
fake := &fakeGalaxyServer{failTest: true}
|
||||||
|
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
_, err := client.TestConnection(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("TestConnection() error = nil, want error")
|
||||||
|
}
|
||||||
|
var gwErr *GatewayError
|
||||||
|
if !errors.As(err, &gwErr) {
|
||||||
|
t.Fatalf("error %T does not support errors.As(*GatewayError)", err)
|
||||||
|
}
|
||||||
|
if gwErr.Op != "galaxy test connection" {
|
||||||
|
t.Fatalf("Op = %q, want %q", gwErr.Op, "galaxy test connection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGalaxyWatchDeployEventsReceivesEventsInOrder(t *testing.T) {
|
||||||
|
bootstrap := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC)
|
||||||
|
deploy1 := time.Date(2026, 4, 28, 10, 5, 0, 0, time.UTC)
|
||||||
|
deploy2 := time.Date(2026, 4, 28, 10, 6, 0, 0, time.UTC)
|
||||||
|
fake := &fakeGalaxyServer{
|
||||||
|
watchEvents: []*pb.DeployEvent{
|
||||||
|
{
|
||||||
|
Sequence: 1,
|
||||||
|
ObservedAt: timestamppb.New(bootstrap),
|
||||||
|
TimeOfLastDeploy: timestamppb.New(deploy1),
|
||||||
|
TimeOfLastDeployPresent: true,
|
||||||
|
ObjectCount: 10,
|
||||||
|
AttributeCount: 42,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Sequence: 2,
|
||||||
|
ObservedAt: timestamppb.New(deploy2),
|
||||||
|
TimeOfLastDeploy: timestamppb.New(deploy2),
|
||||||
|
TimeOfLastDeployPresent: true,
|
||||||
|
ObjectCount: 11,
|
||||||
|
AttributeCount: 44,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
events, errs, err := client.WatchDeployEvents(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WatchDeployEvents() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := make([]*DeployEvent, 0, 2)
|
||||||
|
loop:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case ev, ok := <-events:
|
||||||
|
if !ok {
|
||||||
|
break loop
|
||||||
|
}
|
||||||
|
got = append(got, ev)
|
||||||
|
case errVal := <-errs:
|
||||||
|
if errVal != nil {
|
||||||
|
t.Fatalf("error channel: %v", errVal)
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatalf("timeout waiting for events; got %d", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("len(events) = %d, want 2", len(got))
|
||||||
|
}
|
||||||
|
if got[0].GetSequence() != 1 || got[1].GetSequence() != 2 {
|
||||||
|
t.Fatalf("sequences = [%d,%d], want [1,2]", got[0].GetSequence(), got[1].GetSequence())
|
||||||
|
}
|
||||||
|
if !got[0].GetTimeOfLastDeployPresent() {
|
||||||
|
t.Fatalf("event[0] TimeOfLastDeployPresent = false, want true")
|
||||||
|
}
|
||||||
|
if got[0].GetObjectCount() != 10 || got[0].GetAttributeCount() != 42 {
|
||||||
|
t.Fatalf("event[0] counts = (%d,%d), want (10,42)", got[0].GetObjectCount(), got[0].GetAttributeCount())
|
||||||
|
}
|
||||||
|
if !got[0].GetTimeOfLastDeploy().AsTime().Equal(deploy1) {
|
||||||
|
t.Fatalf("event[0] TimeOfLastDeploy = %v, want %v", got[0].GetTimeOfLastDeploy().AsTime(), deploy1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGalaxyWatchDeployEventsForwardsLastSeenDeployTime(t *testing.T) {
|
||||||
|
fake := &fakeGalaxyServer{
|
||||||
|
watchEvents: []*pb.DeployEvent{
|
||||||
|
{Sequence: 7},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
lastSeen := time.Date(2026, 4, 28, 9, 0, 0, 0, time.UTC)
|
||||||
|
events, errs, err := client.WatchDeployEvents(ctx, &lastSeen)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WatchDeployEvents() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain everything.
|
||||||
|
loop:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case _, ok := <-events:
|
||||||
|
if !ok {
|
||||||
|
break loop
|
||||||
|
}
|
||||||
|
case errVal := <-errs:
|
||||||
|
if errVal != nil {
|
||||||
|
t.Fatalf("error channel: %v", errVal)
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatalf("timeout draining events")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if fake.watchRequest == nil {
|
||||||
|
t.Fatalf("server did not receive a request")
|
||||||
|
}
|
||||||
|
gotTs := fake.watchRequest.GetLastSeenDeployTime()
|
||||||
|
if gotTs == nil {
|
||||||
|
t.Fatalf("LastSeenDeployTime = nil, want %v", lastSeen)
|
||||||
|
}
|
||||||
|
if !gotTs.AsTime().Equal(lastSeen) {
|
||||||
|
t.Fatalf("LastSeenDeployTime = %v, want %v", gotTs.AsTime(), lastSeen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGalaxyWatchDeployEventsCancelTearsDownStream(t *testing.T) {
|
||||||
|
fake := &fakeGalaxyServer{
|
||||||
|
watchEvents: []*pb.DeployEvent{
|
||||||
|
{Sequence: 1},
|
||||||
|
},
|
||||||
|
watchHoldOpen: true,
|
||||||
|
}
|
||||||
|
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
streamCtx, cancelStream := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
events, errs, err := client.WatchDeployEvents(streamCtx, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WatchDeployEvents() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the bootstrap event to arrive.
|
||||||
|
select {
|
||||||
|
case ev, ok := <-events:
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("events channel closed before delivering bootstrap")
|
||||||
|
}
|
||||||
|
if ev.GetSequence() != 1 {
|
||||||
|
t.Fatalf("got seq=%d, want 1", ev.GetSequence())
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatalf("timeout waiting for bootstrap event")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel the stream; both channels must close cleanly without delivering an error.
|
||||||
|
cancelStream()
|
||||||
|
|
||||||
|
deadline := time.After(2 * time.Second)
|
||||||
|
for events != nil || errs != nil {
|
||||||
|
select {
|
||||||
|
case _, ok := <-events:
|
||||||
|
if !ok {
|
||||||
|
events = nil
|
||||||
|
}
|
||||||
|
case errVal, ok := <-errs:
|
||||||
|
if !ok {
|
||||||
|
errs = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if errVal != nil {
|
||||||
|
t.Fatalf("error after cancel: %v", errVal)
|
||||||
|
}
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatalf("channels did not close after cancel; events nil=%v errs nil=%v", events == nil, errs == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGalaxyBufconnClient(t *testing.T, fake *fakeGalaxyServer) (*GalaxyClient, func()) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
listener := bufconn.Listen(bufSize)
|
||||||
|
server := grpc.NewServer()
|
||||||
|
pb.RegisterGalaxyRepositoryServer(server, fake)
|
||||||
|
go func() {
|
||||||
|
if err := server.Serve(listener); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
|
||||||
|
t.Errorf("bufconn server failed: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
|
||||||
|
return listener.DialContext(ctx)
|
||||||
|
}
|
||||||
|
// grpc.NewClient defaults to the dns scheme; use passthrough so the
|
||||||
|
// bufconn fake target reaches the context dialer unresolved.
|
||||||
|
client, err := DialGalaxy(context.Background(), Options{
|
||||||
|
Endpoint: "passthrough:///bufnet",
|
||||||
|
APIKey: "test-api-key",
|
||||||
|
Plaintext: true,
|
||||||
|
DialOptions: []grpc.DialOption{
|
||||||
|
grpc.WithContextDialer(dialer),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DialGalaxy() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return client, func() {
|
||||||
|
client.Close()
|
||||||
|
server.Stop()
|
||||||
|
listener.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeGalaxyServer struct {
|
||||||
|
pb.UnimplementedGalaxyRepositoryServer
|
||||||
|
|
||||||
|
testReply *pb.TestConnectionReply
|
||||||
|
testAuth string
|
||||||
|
failTest bool
|
||||||
|
deployReply *pb.GetLastDeployTimeReply
|
||||||
|
discoverReply *pb.DiscoverHierarchyReply
|
||||||
|
watchEvents []*pb.DeployEvent
|
||||||
|
watchRequest *pb.WatchDeployEventsRequest
|
||||||
|
watchHoldOpen bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeGalaxyServer) TestConnection(ctx context.Context, req *pb.TestConnectionRequest) (*pb.TestConnectionReply, error) {
|
||||||
|
s.testAuth = authorizationFromContext(ctx)
|
||||||
|
if s.failTest {
|
||||||
|
return nil, errors.New("simulated failure")
|
||||||
|
}
|
||||||
|
if s.testReply != nil {
|
||||||
|
return s.testReply, nil
|
||||||
|
}
|
||||||
|
return &pb.TestConnectionReply{Ok: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeGalaxyServer) GetLastDeployTime(ctx context.Context, req *pb.GetLastDeployTimeRequest) (*pb.GetLastDeployTimeReply, error) {
|
||||||
|
if s.deployReply != nil {
|
||||||
|
return s.deployReply, nil
|
||||||
|
}
|
||||||
|
return &pb.GetLastDeployTimeReply{Present: false}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeGalaxyServer) DiscoverHierarchy(ctx context.Context, req *pb.DiscoverHierarchyRequest) (*pb.DiscoverHierarchyReply, error) {
|
||||||
|
if s.discoverReply != nil {
|
||||||
|
return s.discoverReply, nil
|
||||||
|
}
|
||||||
|
return &pb.DiscoverHierarchyReply{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeGalaxyServer) WatchDeployEvents(req *pb.WatchDeployEventsRequest, stream grpc.ServerStreamingServer[pb.DeployEvent]) error {
|
||||||
|
s.watchRequest = req
|
||||||
|
for _, event := range s.watchEvents {
|
||||||
|
if err := stream.Send(event); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.watchHoldOpen {
|
||||||
|
<-stream.Context().Done()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Options configures gateway connections.
|
||||||
|
type Options struct {
|
||||||
|
// Endpoint is the gateway host:port address to dial.
|
||||||
|
Endpoint string
|
||||||
|
// APIKey is the bearer token attached to outgoing gRPC metadata.
|
||||||
|
APIKey string
|
||||||
|
// Plaintext disables TLS and uses insecure credentials when true.
|
||||||
|
Plaintext bool
|
||||||
|
// CACertFile points to a PEM file used to verify the gateway certificate.
|
||||||
|
CACertFile string
|
||||||
|
// ServerNameOverride overrides the TLS SNI/SAN name presented to the gateway.
|
||||||
|
ServerNameOverride string
|
||||||
|
// DialTimeout bounds the blocking Dial; zero applies a built-in default.
|
||||||
|
DialTimeout time.Duration
|
||||||
|
// CallTimeout bounds each unary RPC; zero applies a built-in default and
|
||||||
|
// negative disables the bound entirely.
|
||||||
|
CallTimeout time.Duration
|
||||||
|
// TLSConfig supplies a custom TLS configuration; takes precedence over
|
||||||
|
// CACertFile when TransportCredentials is unset.
|
||||||
|
TLSConfig *tls.Config
|
||||||
|
// TransportCredentials, when non-nil, overrides every other transport-level
|
||||||
|
// option and is used as-is.
|
||||||
|
TransportCredentials credentials.TransportCredentials
|
||||||
|
// DialOptions are appended to the gRPC dial options after the defaults.
|
||||||
|
DialOptions []grpc.DialOption
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedactedAPIKey returns a display-safe representation of the configured API
|
||||||
|
// key for diagnostics and CLI output.
|
||||||
|
func (o Options) RedactedAPIKey() string {
|
||||||
|
return RedactAPIKey(o.APIKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedactAPIKey hides credential material while keeping enough shape for
|
||||||
|
// troubleshooting whether a key was supplied.
|
||||||
|
func RedactAPIKey(apiKey string) string {
|
||||||
|
if apiKey == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(apiKey) <= 8 {
|
||||||
|
return "<redacted>"
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix, suffix := apiKey[:4], apiKey[len(apiKey)-4:]
|
||||||
|
return prefix + strings.Repeat("*", len(apiKey)-8) + suffix
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestRedactAPIKey(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
apiKey string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "empty", apiKey: "", want: ""},
|
||||||
|
{name: "short", apiKey: "mxgw_1", want: "<redacted>"},
|
||||||
|
{name: "long", apiKey: "mxgw_key_secret", want: "mxgw*******cret"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := RedactAPIKey(tt.apiKey); got != tt.want {
|
||||||
|
t.Fatalf("RedactAPIKey() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGeneratedGoldenFixturesParse(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
msg proto.Message
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "open session reply",
|
||||||
|
path: filepath.Join("..", "..", "proto", "fixtures", "golden", "open-session-reply.ok.json"),
|
||||||
|
msg: &pb.OpenSessionReply{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "register command request",
|
||||||
|
path: filepath.Join("..", "..", "proto", "fixtures", "golden", "register-command-request.json"),
|
||||||
|
msg: &pb.MxCommandRequest{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "on data change event",
|
||||||
|
path: filepath.Join("..", "..", "proto", "fixtures", "golden", "on-data-change-event.json"),
|
||||||
|
msg: &pb.MxEvent{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
unmarshal := protojson.UnmarshalOptions{DiscardUnknown: false}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
data, err := os.ReadFile(tt.path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := unmarshal.Unmarshal(data, tt.msg); err != nil {
|
||||||
|
t.Fatalf("parse fixture: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenSessionFixtureProtocolVersions(t *testing.T) {
|
||||||
|
data, err := os.ReadFile(filepath.Join("..", "..", "proto", "fixtures", "golden", "open-session-reply.ok.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var reply pb.OpenSessionReply
|
||||||
|
if err := protojson.Unmarshal(data, &reply); err != nil {
|
||||||
|
t.Fatalf("parse fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if reply.GetGatewayProtocolVersion() != GatewayProtocolVersion {
|
||||||
|
t.Fatalf("gateway protocol = %d, want %d", reply.GetGatewayProtocolVersion(), GatewayProtocolVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
if reply.GetWorkerProtocolVersion() != WorkerProtocolVersion {
|
||||||
|
t.Fatalf("worker protocol = %d, want %d", reply.GetWorkerProtocolVersion(), WorkerProtocolVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,709 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxBulkItems = 1000
|
||||||
|
|
||||||
|
// EventResult carries either the next ordered event or a terminal stream error.
|
||||||
|
type EventResult struct {
|
||||||
|
// Event is the next event from the stream when Err is nil.
|
||||||
|
Event *MxEvent
|
||||||
|
// Err is the terminal stream error; when non-nil no further results follow.
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventSubscription owns a running gateway event stream.
|
||||||
|
type EventSubscription struct {
|
||||||
|
results <-chan EventResult
|
||||||
|
cancel context.CancelFunc
|
||||||
|
done <-chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events returns the stream results channel.
|
||||||
|
func (s *EventSubscription) Events() <-chan EventResult {
|
||||||
|
return s.results
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close cancels the stream and waits for the receive goroutine to stop.
|
||||||
|
func (s *EventSubscription) Close() {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.once.Do(func() {
|
||||||
|
s.cancel()
|
||||||
|
<-s.done
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session represents one gateway-backed MXAccess session.
|
||||||
|
type Session struct {
|
||||||
|
client *Client
|
||||||
|
openReply *OpenSessionReply
|
||||||
|
closeMu sync.Mutex
|
||||||
|
closeReply *CloseSessionReply
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSession(client *Client, openReply *OpenSessionReply) *Session {
|
||||||
|
return &Session{
|
||||||
|
client: client,
|
||||||
|
openReply: openReply,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSessionForID creates a session wrapper for commands against an existing
|
||||||
|
// gateway session id.
|
||||||
|
func NewSessionForID(client *Client, sessionID string) *Session {
|
||||||
|
return newSession(client, &pb.OpenSessionReply{SessionId: sessionID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID returns the gateway session identifier.
|
||||||
|
func (s *Session) ID() string {
|
||||||
|
return s.openReply.GetSessionId()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenReply returns the raw OpenSession reply.
|
||||||
|
func (s *Session) OpenReply() *OpenSessionReply {
|
||||||
|
return s.openReply
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the gateway session once and returns the raw close reply.
|
||||||
|
func (s *Session) Close(ctx context.Context) (*CloseSessionReply, error) {
|
||||||
|
s.closeMu.Lock()
|
||||||
|
defer s.closeMu.Unlock()
|
||||||
|
|
||||||
|
if s.closeReply != nil {
|
||||||
|
return s.closeReply, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
reply, err := s.client.CloseSessionRaw(ctx, &pb.CloseSessionRequest{SessionId: s.ID()})
|
||||||
|
if err != nil {
|
||||||
|
return reply, err
|
||||||
|
}
|
||||||
|
s.closeReply = reply
|
||||||
|
return reply, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register invokes MXAccess Register and returns the server handle.
|
||||||
|
func (s *Session) Register(ctx context.Context, clientName string) (int32, error) {
|
||||||
|
reply, err := s.RegisterRaw(ctx, clientName)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if reply.GetRegister() != nil {
|
||||||
|
return reply.GetRegister().GetServerHandle(), nil
|
||||||
|
}
|
||||||
|
return reply.GetReturnValue().GetInt32Value(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRaw invokes MXAccess Register and returns the raw reply.
|
||||||
|
func (s *Session) RegisterRaw(ctx context.Context, clientName string) (*MxCommandReply, error) {
|
||||||
|
if clientName == "" {
|
||||||
|
return nil, errors.New("mxgateway: client name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_REGISTER,
|
||||||
|
Payload: &pb.MxCommand_Register{
|
||||||
|
Register: &pb.RegisterCommand{ClientName: clientName},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregister invokes MXAccess Unregister.
|
||||||
|
func (s *Session) Unregister(ctx context.Context, serverHandle int32) error {
|
||||||
|
_, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_UNREGISTER,
|
||||||
|
Payload: &pb.MxCommand_Unregister{
|
||||||
|
Unregister: &pb.UnregisterCommand{ServerHandle: serverHandle},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveItem invokes MXAccess RemoveItem.
|
||||||
|
func (s *Session) RemoveItem(ctx context.Context, serverHandle, itemHandle int32) error {
|
||||||
|
_, err := s.RemoveItemRaw(ctx, serverHandle, itemHandle)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveItemRaw invokes MXAccess RemoveItem and returns the raw reply.
|
||||||
|
func (s *Session) RemoveItemRaw(ctx context.Context, serverHandle, itemHandle int32) (*MxCommandReply, error) {
|
||||||
|
return s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_REMOVE_ITEM,
|
||||||
|
Payload: &pb.MxCommand_RemoveItem{
|
||||||
|
RemoveItem: &pb.RemoveItemCommand{
|
||||||
|
ServerHandle: serverHandle,
|
||||||
|
ItemHandle: itemHandle,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddItem invokes MXAccess AddItem and returns the item handle.
|
||||||
|
func (s *Session) AddItem(ctx context.Context, serverHandle int32, itemDefinition string) (int32, error) {
|
||||||
|
reply, err := s.AddItemRaw(ctx, serverHandle, itemDefinition)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if reply.GetAddItem() != nil {
|
||||||
|
return reply.GetAddItem().GetItemHandle(), nil
|
||||||
|
}
|
||||||
|
return reply.GetReturnValue().GetInt32Value(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddItemRaw invokes MXAccess AddItem and returns the raw reply.
|
||||||
|
func (s *Session) AddItemRaw(ctx context.Context, serverHandle int32, itemDefinition string) (*MxCommandReply, error) {
|
||||||
|
if itemDefinition == "" {
|
||||||
|
return nil, errors.New("mxgateway: item definition is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM,
|
||||||
|
Payload: &pb.MxCommand_AddItem{
|
||||||
|
AddItem: &pb.AddItemCommand{
|
||||||
|
ServerHandle: serverHandle,
|
||||||
|
ItemDefinition: itemDefinition,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddItem2 invokes MXAccess AddItem2 and returns the item handle.
|
||||||
|
func (s *Session) AddItem2(ctx context.Context, serverHandle int32, itemDefinition, itemContext string) (int32, error) {
|
||||||
|
reply, err := s.AddItem2Raw(ctx, serverHandle, itemDefinition, itemContext)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if reply.GetAddItem2() != nil {
|
||||||
|
return reply.GetAddItem2().GetItemHandle(), nil
|
||||||
|
}
|
||||||
|
return reply.GetReturnValue().GetInt32Value(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddItem2Raw invokes MXAccess AddItem2 and returns the raw reply.
|
||||||
|
func (s *Session) AddItem2Raw(ctx context.Context, serverHandle int32, itemDefinition, itemContext string) (*MxCommandReply, error) {
|
||||||
|
if itemDefinition == "" {
|
||||||
|
return nil, errors.New("mxgateway: item definition is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM2,
|
||||||
|
Payload: &pb.MxCommand_AddItem2{
|
||||||
|
AddItem2: &pb.AddItem2Command{
|
||||||
|
ServerHandle: serverHandle,
|
||||||
|
ItemDefinition: itemDefinition,
|
||||||
|
ItemContext: itemContext,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advise invokes MXAccess Advise.
|
||||||
|
func (s *Session) Advise(ctx context.Context, serverHandle, itemHandle int32) error {
|
||||||
|
_, err := s.AdviseRaw(ctx, serverHandle, itemHandle)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdviseRaw invokes MXAccess Advise and returns the raw reply.
|
||||||
|
func (s *Session) AdviseRaw(ctx context.Context, serverHandle, itemHandle int32) (*MxCommandReply, error) {
|
||||||
|
return s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE,
|
||||||
|
Payload: &pb.MxCommand_Advise{
|
||||||
|
Advise: &pb.AdviseCommand{
|
||||||
|
ServerHandle: serverHandle,
|
||||||
|
ItemHandle: itemHandle,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnAdvise invokes MXAccess UnAdvise.
|
||||||
|
func (s *Session) UnAdvise(ctx context.Context, serverHandle, itemHandle int32) error {
|
||||||
|
_, err := s.UnAdviseRaw(ctx, serverHandle, itemHandle)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnAdviseRaw invokes MXAccess UnAdvise and returns the raw reply.
|
||||||
|
func (s *Session) UnAdviseRaw(ctx context.Context, serverHandle, itemHandle int32) (*MxCommandReply, error) {
|
||||||
|
return s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_UN_ADVISE,
|
||||||
|
Payload: &pb.MxCommand_UnAdvise{
|
||||||
|
UnAdvise: &pb.UnAdviseCommand{
|
||||||
|
ServerHandle: serverHandle,
|
||||||
|
ItemHandle: itemHandle,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddItemBulk invokes MXAccess AddItem for each tag inside one gateway command.
|
||||||
|
func (s *Session) AddItemBulk(ctx context.Context, serverHandle int32, tagAddresses []string) ([]*SubscribeResult, error) {
|
||||||
|
if tagAddresses == nil {
|
||||||
|
return nil, errors.New("mxgateway: tag addresses are required")
|
||||||
|
}
|
||||||
|
if err := ensureBulkSize("tag addresses", len(tagAddresses)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM_BULK,
|
||||||
|
Payload: &pb.MxCommand_AddItemBulk{
|
||||||
|
AddItemBulk: &pb.AddItemBulkCommand{
|
||||||
|
ServerHandle: serverHandle,
|
||||||
|
TagAddresses: tagAddresses,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reply.GetAddItemBulk().GetResults(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdviseItemBulk invokes MXAccess Advise for each item handle inside one gateway command.
|
||||||
|
func (s *Session) AdviseItemBulk(ctx context.Context, serverHandle int32, itemHandles []int32) ([]*SubscribeResult, error) {
|
||||||
|
if itemHandles == nil {
|
||||||
|
return nil, errors.New("mxgateway: item handles are required")
|
||||||
|
}
|
||||||
|
if err := ensureBulkSize("item handles", len(itemHandles)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_ITEM_BULK,
|
||||||
|
Payload: &pb.MxCommand_AdviseItemBulk{
|
||||||
|
AdviseItemBulk: &pb.AdviseItemBulkCommand{
|
||||||
|
ServerHandle: serverHandle,
|
||||||
|
ItemHandles: itemHandles,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reply.GetAdviseItemBulk().GetResults(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveItemBulk invokes MXAccess RemoveItem for each item handle inside one gateway command.
|
||||||
|
func (s *Session) RemoveItemBulk(ctx context.Context, serverHandle int32, itemHandles []int32) ([]*SubscribeResult, error) {
|
||||||
|
if itemHandles == nil {
|
||||||
|
return nil, errors.New("mxgateway: item handles are required")
|
||||||
|
}
|
||||||
|
if err := ensureBulkSize("item handles", len(itemHandles)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_REMOVE_ITEM_BULK,
|
||||||
|
Payload: &pb.MxCommand_RemoveItemBulk{
|
||||||
|
RemoveItemBulk: &pb.RemoveItemBulkCommand{
|
||||||
|
ServerHandle: serverHandle,
|
||||||
|
ItemHandles: itemHandles,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reply.GetRemoveItemBulk().GetResults(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnAdviseItemBulk invokes MXAccess UnAdvise for each item handle inside one gateway command.
|
||||||
|
func (s *Session) UnAdviseItemBulk(ctx context.Context, serverHandle int32, itemHandles []int32) ([]*SubscribeResult, error) {
|
||||||
|
if itemHandles == nil {
|
||||||
|
return nil, errors.New("mxgateway: item handles are required")
|
||||||
|
}
|
||||||
|
if err := ensureBulkSize("item handles", len(itemHandles)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_UN_ADVISE_ITEM_BULK,
|
||||||
|
Payload: &pb.MxCommand_UnAdviseItemBulk{
|
||||||
|
UnAdviseItemBulk: &pb.UnAdviseItemBulkCommand{
|
||||||
|
ServerHandle: serverHandle,
|
||||||
|
ItemHandles: itemHandles,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reply.GetUnAdviseItemBulk().GetResults(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscribeBulk invokes AddItem and Advise for each tag inside one gateway command.
|
||||||
|
func (s *Session) SubscribeBulk(ctx context.Context, serverHandle int32, tagAddresses []string) ([]*SubscribeResult, error) {
|
||||||
|
if tagAddresses == nil {
|
||||||
|
return nil, errors.New("mxgateway: tag addresses are required")
|
||||||
|
}
|
||||||
|
if err := ensureBulkSize("tag addresses", len(tagAddresses)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_SUBSCRIBE_BULK,
|
||||||
|
Payload: &pb.MxCommand_SubscribeBulk{
|
||||||
|
SubscribeBulk: &pb.SubscribeBulkCommand{
|
||||||
|
ServerHandle: serverHandle,
|
||||||
|
TagAddresses: tagAddresses,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reply.GetSubscribeBulk().GetResults(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnsubscribeBulk invokes UnAdvise and RemoveItem for each item handle inside one gateway command.
|
||||||
|
func (s *Session) UnsubscribeBulk(ctx context.Context, serverHandle int32, itemHandles []int32) ([]*SubscribeResult, error) {
|
||||||
|
if itemHandles == nil {
|
||||||
|
return nil, errors.New("mxgateway: item handles are required")
|
||||||
|
}
|
||||||
|
if err := ensureBulkSize("item handles", len(itemHandles)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_UNSUBSCRIBE_BULK,
|
||||||
|
Payload: &pb.MxCommand_UnsubscribeBulk{
|
||||||
|
UnsubscribeBulk: &pb.UnsubscribeBulkCommand{
|
||||||
|
ServerHandle: serverHandle,
|
||||||
|
ItemHandles: itemHandles,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reply.GetUnsubscribeBulk().GetResults(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteBulk invokes MXAccess Write sequentially for each entry inside one gateway command.
|
||||||
|
// Per-entry failures appear as BulkWriteResult entries with WasSuccessful=false; the call
|
||||||
|
// never returns an error for per-entry MXAccess failures (it returns an error only for
|
||||||
|
// protocol-level failures or transport errors).
|
||||||
|
func (s *Session) WriteBulk(ctx context.Context, serverHandle int32, entries []*WriteBulkEntry) ([]*BulkWriteResult, error) {
|
||||||
|
if entries == nil {
|
||||||
|
return nil, errors.New("mxgateway: write bulk entries are required")
|
||||||
|
}
|
||||||
|
if err := ensureBulkSize("write bulk entries", len(entries)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_BULK,
|
||||||
|
Payload: &pb.MxCommand_WriteBulk{
|
||||||
|
WriteBulk: &pb.WriteBulkCommand{
|
||||||
|
ServerHandle: serverHandle,
|
||||||
|
Entries: entries,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reply.GetWriteBulk().GetResults(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write2Bulk invokes MXAccess Write2 (timestamped) for each entry inside one gateway command.
|
||||||
|
func (s *Session) Write2Bulk(ctx context.Context, serverHandle int32, entries []*Write2BulkEntry) ([]*BulkWriteResult, error) {
|
||||||
|
if entries == nil {
|
||||||
|
return nil, errors.New("mxgateway: write2 bulk entries are required")
|
||||||
|
}
|
||||||
|
if err := ensureBulkSize("write2 bulk entries", len(entries)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE2_BULK,
|
||||||
|
Payload: &pb.MxCommand_Write2Bulk{
|
||||||
|
Write2Bulk: &pb.Write2BulkCommand{
|
||||||
|
ServerHandle: serverHandle,
|
||||||
|
Entries: entries,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reply.GetWrite2Bulk().GetResults(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteSecuredBulk invokes MXAccess WriteSecured for each entry. Credential-sensitive
|
||||||
|
// values must not be logged by callers; mirrors the single-item WriteSecured contract.
|
||||||
|
func (s *Session) WriteSecuredBulk(ctx context.Context, serverHandle int32, entries []*WriteSecuredBulkEntry) ([]*BulkWriteResult, error) {
|
||||||
|
if entries == nil {
|
||||||
|
return nil, errors.New("mxgateway: write-secured bulk entries are required")
|
||||||
|
}
|
||||||
|
if err := ensureBulkSize("write-secured bulk entries", len(entries)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED_BULK,
|
||||||
|
Payload: &pb.MxCommand_WriteSecuredBulk{
|
||||||
|
WriteSecuredBulk: &pb.WriteSecuredBulkCommand{
|
||||||
|
ServerHandle: serverHandle,
|
||||||
|
Entries: entries,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reply.GetWriteSecuredBulk().GetResults(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteSecured2Bulk invokes MXAccess WriteSecured2 (timestamped) for each entry.
|
||||||
|
func (s *Session) WriteSecured2Bulk(ctx context.Context, serverHandle int32, entries []*WriteSecured2BulkEntry) ([]*BulkWriteResult, error) {
|
||||||
|
if entries == nil {
|
||||||
|
return nil, errors.New("mxgateway: write-secured2 bulk entries are required")
|
||||||
|
}
|
||||||
|
if err := ensureBulkSize("write-secured2 bulk entries", len(entries)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED2_BULK,
|
||||||
|
Payload: &pb.MxCommand_WriteSecured2Bulk{
|
||||||
|
WriteSecured2Bulk: &pb.WriteSecured2BulkCommand{
|
||||||
|
ServerHandle: serverHandle,
|
||||||
|
Entries: entries,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reply.GetWriteSecured2Bulk().GetResults(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadBulk snapshots the current value of each requested tag.
|
||||||
|
//
|
||||||
|
// MXAccess COM has no synchronous Read; the worker satisfies this by returning the
|
||||||
|
// most recent cached OnDataChange value when the tag is already advised (WasCached=true),
|
||||||
|
// or by taking a full AddItem + Advise + wait + UnAdvise + RemoveItem snapshot lifecycle
|
||||||
|
// otherwise. timeout bounds the wait per tag in the snapshot case; pass zero to use the
|
||||||
|
// worker default. Per-tag failures (timeout, invalid tag) appear as BulkReadResult entries
|
||||||
|
// with WasSuccessful=false; the call never returns an error for per-tag MXAccess failures.
|
||||||
|
func (s *Session) ReadBulk(ctx context.Context, serverHandle int32, tagAddresses []string, timeout time.Duration) ([]*BulkReadResult, error) {
|
||||||
|
if tagAddresses == nil {
|
||||||
|
return nil, errors.New("mxgateway: tag addresses are required")
|
||||||
|
}
|
||||||
|
if err := ensureBulkSize("tag addresses", len(tagAddresses)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var timeoutMs uint32
|
||||||
|
if timeout > 0 {
|
||||||
|
ms := timeout.Milliseconds()
|
||||||
|
if ms > int64(^uint32(0)) {
|
||||||
|
timeoutMs = ^uint32(0)
|
||||||
|
} else {
|
||||||
|
timeoutMs = uint32(ms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK,
|
||||||
|
Payload: &pb.MxCommand_ReadBulk{
|
||||||
|
ReadBulk: &pb.ReadBulkCommand{
|
||||||
|
ServerHandle: serverHandle,
|
||||||
|
TagAddresses: tagAddresses,
|
||||||
|
TimeoutMs: timeoutMs,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reply.GetReadBulk().GetResults(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write invokes MXAccess Write.
|
||||||
|
func (s *Session) Write(ctx context.Context, serverHandle, itemHandle int32, value *MxValue, userID int32) error {
|
||||||
|
_, err := s.WriteRaw(ctx, serverHandle, itemHandle, value, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteRaw invokes MXAccess Write and returns the raw reply.
|
||||||
|
func (s *Session) WriteRaw(ctx context.Context, serverHandle, itemHandle int32, value *MxValue, userID int32) (*MxCommandReply, error) {
|
||||||
|
if value == nil {
|
||||||
|
return nil, errors.New("mxgateway: write value is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE,
|
||||||
|
Payload: &pb.MxCommand_Write{
|
||||||
|
Write: &pb.WriteCommand{
|
||||||
|
ServerHandle: serverHandle,
|
||||||
|
ItemHandle: itemHandle,
|
||||||
|
Value: value,
|
||||||
|
UserId: userID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events streams ordered session events until the server ends the stream,
|
||||||
|
// context cancellation stops Recv, or a terminal error is sent.
|
||||||
|
func (s *Session) Events(ctx context.Context) (<-chan EventResult, error) {
|
||||||
|
return s.EventsAfter(ctx, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventsAfter streams ordered session events after the given worker sequence.
|
||||||
|
func (s *Session) EventsAfter(ctx context.Context, afterWorkerSequence uint64) (<-chan EventResult, error) {
|
||||||
|
subscription, err := s.subscribeEventsAfter(ctx, afterWorkerSequence, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return subscription.Events(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscribeEvents starts an owned event subscription.
|
||||||
|
func (s *Session) SubscribeEvents(ctx context.Context) (*EventSubscription, error) {
|
||||||
|
return s.SubscribeEventsAfter(ctx, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscribeEventsAfter starts an owned event subscription after the given worker sequence.
|
||||||
|
func (s *Session) SubscribeEventsAfter(ctx context.Context, afterWorkerSequence uint64) (*EventSubscription, error) {
|
||||||
|
return s.subscribeEventsAfter(ctx, afterWorkerSequence, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) subscribeEventsAfter(ctx context.Context, afterWorkerSequence uint64, cancelWhenResultBufferFull bool) (*EventSubscription, error) {
|
||||||
|
streamCtx, cancel := context.WithCancel(ctx)
|
||||||
|
stream, err := s.client.StreamEventsRaw(streamCtx, &pb.StreamEventsRequest{
|
||||||
|
SessionId: s.ID(),
|
||||||
|
AfterWorkerSequence: afterWorkerSequence,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make(chan EventResult, 16)
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(results)
|
||||||
|
defer close(done)
|
||||||
|
for {
|
||||||
|
event, err := stream.Recv()
|
||||||
|
if err == nil {
|
||||||
|
if !sendEventResult(streamCtx, results, EventResult{Event: event}, cancelWhenResultBufferFull, cancel) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if errors.Is(err, io.EOF) || status.Code(err) == codes.Canceled || streamCtx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendEventResult(
|
||||||
|
streamCtx,
|
||||||
|
results,
|
||||||
|
EventResult{Err: &GatewayError{Op: "stream events", Err: err}},
|
||||||
|
cancelWhenResultBufferFull,
|
||||||
|
cancel)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return &EventSubscription{
|
||||||
|
results: results,
|
||||||
|
cancel: cancel,
|
||||||
|
done: done,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureBulkSize(name string, length int) error {
|
||||||
|
if length > maxBulkItems {
|
||||||
|
return fmt.Errorf("mxgateway: %s bulk commands are limited to %d item(s)", name, maxBulkItems)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendEventResult(
|
||||||
|
ctx context.Context,
|
||||||
|
results chan EventResult,
|
||||||
|
result EventResult,
|
||||||
|
cancelWhenBufferFull bool,
|
||||||
|
cancel context.CancelFunc,
|
||||||
|
) bool {
|
||||||
|
if cancelWhenBufferFull {
|
||||||
|
select {
|
||||||
|
case results <- result:
|
||||||
|
return true
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
// The bounded compatibility buffer is full. Cancel the stream and
|
||||||
|
// deliver an explicit terminal overflow error so a slow consumer
|
||||||
|
// can tell dropped events apart from a normal end-of-stream,
|
||||||
|
// rather than seeing the channel close silently.
|
||||||
|
cancel()
|
||||||
|
deliverTerminalResult(results, EventResult{Err: ErrEventBufferOverflow})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case results <- result:
|
||||||
|
return true
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverTerminalResult places result on a full buffered channel by evicting
|
||||||
|
// one of the oldest buffered events to make room. The caller closes results
|
||||||
|
// afterwards, so the terminal result becomes the consumer's last item.
|
||||||
|
func deliverTerminalResult(results chan EventResult, result EventResult) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case results <- result:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-results:
|
||||||
|
default:
|
||||||
|
// Another receiver drained the channel between the send and
|
||||||
|
// receive attempts; retry the send.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) invokeCommand(ctx context.Context, command *MxCommand) (*MxCommandReply, error) {
|
||||||
|
return s.client.Invoke(ctx, &pb.MxCommandRequest{
|
||||||
|
SessionId: s.ID(),
|
||||||
|
ClientCorrelationId: newCorrelationID(),
|
||||||
|
Command: command,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// correlationIDCounter backs the deterministic fallback id used when
|
||||||
|
// crypto/rand is unavailable, so every command still carries a unique,
|
||||||
|
// traceable correlation id.
|
||||||
|
var correlationIDCounter atomic.Uint64
|
||||||
|
|
||||||
|
// randRead is the entropy source for newCorrelationID. It is a package
|
||||||
|
// variable solely so tests can simulate a crypto/rand failure.
|
||||||
|
var randRead = rand.Read
|
||||||
|
|
||||||
|
// newCorrelationID returns a unique correlation id for an MxCommandRequest.
|
||||||
|
// It prefers 16 bytes of crypto/rand entropy; if rand.Read fails (rare) it
|
||||||
|
// falls back to a "fallback-" prefixed id built from the current time and a
|
||||||
|
// process-wide monotonic counter rather than returning an empty string, which
|
||||||
|
// would leave the command untraceable in gateway logs.
|
||||||
|
func newCorrelationID() string {
|
||||||
|
var buffer [16]byte
|
||||||
|
if _, err := randRead(buffer[:]); err != nil {
|
||||||
|
return fmt.Sprintf("fallback-%x-%x",
|
||||||
|
time.Now().UnixNano(), correlationIDCounter.Add(1))
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buffer[:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
// StatusSucceeded reports whether an MXSTATUS_PROXY entry represents success.
|
||||||
|
func StatusSucceeded(status *MxStatusProxy) bool {
|
||||||
|
return status == nil || status.GetSuccess() != 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
|
||||||
|
// RawGatewayClient is the generated gRPC client interface exposed for callers
|
||||||
|
// that need direct contract access.
|
||||||
|
type RawGatewayClient = pb.MxAccessGatewayClient
|
||||||
|
|
||||||
|
// RawEventStream is the generated StreamEvents client stream.
|
||||||
|
type RawEventStream = pb.MxAccessGateway_StreamEventsClient
|
||||||
|
|
||||||
|
// Generated protobuf aliases keep raw contract access available from the public
|
||||||
|
// mxgateway package while generated code remains under internal/generated.
|
||||||
|
type (
|
||||||
|
// OpenSessionRequest is the gateway OpenSession request message.
|
||||||
|
OpenSessionRequest = pb.OpenSessionRequest
|
||||||
|
// OpenSessionReply is the gateway OpenSession reply message.
|
||||||
|
OpenSessionReply = pb.OpenSessionReply
|
||||||
|
// CloseSessionRequest is the gateway CloseSession request message.
|
||||||
|
CloseSessionRequest = pb.CloseSessionRequest
|
||||||
|
// CloseSessionReply is the gateway CloseSession reply message.
|
||||||
|
CloseSessionReply = pb.CloseSessionReply
|
||||||
|
// StreamEventsRequest is the gateway StreamEvents request message.
|
||||||
|
StreamEventsRequest = pb.StreamEventsRequest
|
||||||
|
// MxCommandRequest carries one MXAccess command for Invoke.
|
||||||
|
MxCommandRequest = pb.MxCommandRequest
|
||||||
|
// MxCommandReply is the reply to an MXAccess command Invoke.
|
||||||
|
MxCommandReply = pb.MxCommandReply
|
||||||
|
// MxCommand is the discriminated union of MXAccess command payloads.
|
||||||
|
MxCommand = pb.MxCommand
|
||||||
|
// MxEvent is one ordered event delivered on a session event stream.
|
||||||
|
MxEvent = pb.MxEvent
|
||||||
|
// MxValue is the protobuf representation of an MXAccess value.
|
||||||
|
MxValue = pb.MxValue
|
||||||
|
// Value is an alias for MxValue retained for symmetry with other clients.
|
||||||
|
Value = pb.MxValue
|
||||||
|
// MxArray is the protobuf representation of an MXAccess array value.
|
||||||
|
MxArray = pb.MxArray
|
||||||
|
// MxStatusProxy mirrors the MXAccess MXSTATUS_PROXY structure.
|
||||||
|
MxStatusProxy = pb.MxStatusProxy
|
||||||
|
// ProtocolStatus is the gateway-level status carried on every reply.
|
||||||
|
ProtocolStatus = pb.ProtocolStatus
|
||||||
|
// RegisterCommand is the payload of an MXAccess Register command.
|
||||||
|
RegisterCommand = pb.RegisterCommand
|
||||||
|
// UnregisterCommand is the payload of an MXAccess Unregister command.
|
||||||
|
UnregisterCommand = pb.UnregisterCommand
|
||||||
|
// AddItemCommand is the payload of an MXAccess AddItem command.
|
||||||
|
AddItemCommand = pb.AddItemCommand
|
||||||
|
// AddItem2Command is the payload of an MXAccess AddItem2 command.
|
||||||
|
AddItem2Command = pb.AddItem2Command
|
||||||
|
// RemoveItemCommand is the payload of an MXAccess RemoveItem command.
|
||||||
|
RemoveItemCommand = pb.RemoveItemCommand
|
||||||
|
// AdviseCommand is the payload of an MXAccess Advise command.
|
||||||
|
AdviseCommand = pb.AdviseCommand
|
||||||
|
// UnAdviseCommand is the payload of an MXAccess UnAdvise command.
|
||||||
|
UnAdviseCommand = pb.UnAdviseCommand
|
||||||
|
// AddItemBulkCommand is the payload of an AddItem bulk command.
|
||||||
|
AddItemBulkCommand = pb.AddItemBulkCommand
|
||||||
|
// AdviseItemBulkCommand is the payload of an Advise bulk command.
|
||||||
|
AdviseItemBulkCommand = pb.AdviseItemBulkCommand
|
||||||
|
// RemoveItemBulkCommand is the payload of a RemoveItem bulk command.
|
||||||
|
RemoveItemBulkCommand = pb.RemoveItemBulkCommand
|
||||||
|
// UnAdviseItemBulkCommand is the payload of an UnAdvise bulk command.
|
||||||
|
UnAdviseItemBulkCommand = pb.UnAdviseItemBulkCommand
|
||||||
|
// SubscribeBulkCommand combines AddItem and Advise for a list of tags.
|
||||||
|
SubscribeBulkCommand = pb.SubscribeBulkCommand
|
||||||
|
// UnsubscribeBulkCommand combines UnAdvise and RemoveItem for a list of items.
|
||||||
|
UnsubscribeBulkCommand = pb.UnsubscribeBulkCommand
|
||||||
|
// WriteCommand is the payload of an MXAccess Write command.
|
||||||
|
WriteCommand = pb.WriteCommand
|
||||||
|
// Write2Command is the payload of an MXAccess Write2 command.
|
||||||
|
Write2Command = pb.Write2Command
|
||||||
|
// WriteBulkCommand carries one bulk-Write request.
|
||||||
|
WriteBulkCommand = pb.WriteBulkCommand
|
||||||
|
// WriteBulkEntry is one (item_handle, value, user_id) tuple in a WriteBulk request.
|
||||||
|
WriteBulkEntry = pb.WriteBulkEntry
|
||||||
|
// Write2BulkCommand carries one bulk-Write2 (timestamped) request.
|
||||||
|
Write2BulkCommand = pb.Write2BulkCommand
|
||||||
|
// Write2BulkEntry is one (item_handle, value, timestamp_value, user_id) tuple in a Write2Bulk request.
|
||||||
|
Write2BulkEntry = pb.Write2BulkEntry
|
||||||
|
// WriteSecuredBulkCommand carries one bulk-WriteSecured request. Values are credential-sensitive.
|
||||||
|
WriteSecuredBulkCommand = pb.WriteSecuredBulkCommand
|
||||||
|
// WriteSecuredBulkEntry is one entry in a WriteSecuredBulk request.
|
||||||
|
WriteSecuredBulkEntry = pb.WriteSecuredBulkEntry
|
||||||
|
// WriteSecured2BulkCommand carries one bulk-WriteSecured2 (timestamped) request.
|
||||||
|
WriteSecured2BulkCommand = pb.WriteSecured2BulkCommand
|
||||||
|
// WriteSecured2BulkEntry is one entry in a WriteSecured2Bulk request.
|
||||||
|
WriteSecured2BulkEntry = pb.WriteSecured2BulkEntry
|
||||||
|
// ReadBulkCommand carries one bulk-Read request.
|
||||||
|
ReadBulkCommand = pb.ReadBulkCommand
|
||||||
|
// BulkWriteResult is one per-entry result in a bulk-write reply.
|
||||||
|
BulkWriteResult = pb.BulkWriteResult
|
||||||
|
// BulkWriteReply aggregates BulkWriteResult entries for a bulk-write command.
|
||||||
|
BulkWriteReply = pb.BulkWriteReply
|
||||||
|
// BulkReadResult is one per-tag result in a bulk-read reply (carries the snapshot value plus a was_cached flag).
|
||||||
|
BulkReadResult = pb.BulkReadResult
|
||||||
|
// BulkReadReply aggregates BulkReadResult entries for a ReadBulk command.
|
||||||
|
BulkReadReply = pb.BulkReadReply
|
||||||
|
// RegisterReply carries the ServerHandle returned by Register.
|
||||||
|
RegisterReply = pb.RegisterReply
|
||||||
|
// AddItemReply carries the ItemHandle returned by AddItem.
|
||||||
|
AddItemReply = pb.AddItemReply
|
||||||
|
// AddItem2Reply carries the ItemHandle returned by AddItem2.
|
||||||
|
AddItem2Reply = pb.AddItem2Reply
|
||||||
|
// SubscribeResult is one entry in a bulk command result list.
|
||||||
|
SubscribeResult = pb.SubscribeResult
|
||||||
|
// BulkSubscribeReply aggregates SubscribeResult entries for a bulk command.
|
||||||
|
BulkSubscribeReply = pb.BulkSubscribeReply
|
||||||
|
// AcknowledgeAlarmRequest is the gateway AcknowledgeAlarm request message.
|
||||||
|
AcknowledgeAlarmRequest = pb.AcknowledgeAlarmRequest
|
||||||
|
// AcknowledgeAlarmReply is the gateway AcknowledgeAlarm reply message.
|
||||||
|
AcknowledgeAlarmReply = pb.AcknowledgeAlarmReply
|
||||||
|
// StreamAlarmsRequest is the gateway StreamAlarms request message.
|
||||||
|
StreamAlarmsRequest = pb.StreamAlarmsRequest
|
||||||
|
// AlarmFeedMessage is one message on the StreamAlarms feed — an
|
||||||
|
// active-alarm snapshot row, a snapshot-complete sentinel, or a transition.
|
||||||
|
AlarmFeedMessage = pb.AlarmFeedMessage
|
||||||
|
// ActiveAlarmSnapshot is one currently-active alarm in the feed snapshot.
|
||||||
|
ActiveAlarmSnapshot = pb.ActiveAlarmSnapshot
|
||||||
|
// OnAlarmTransitionEvent is the body carried by alarm-transition MxEvents.
|
||||||
|
OnAlarmTransitionEvent = pb.OnAlarmTransitionEvent
|
||||||
|
)
|
||||||
|
|
||||||
|
// AlarmTransitionKind discriminates raise / acknowledge / clear / retrigger
|
||||||
|
// transitions on an OnAlarmTransitionEvent.
|
||||||
|
type AlarmTransitionKind = pb.AlarmTransitionKind
|
||||||
|
|
||||||
|
// AlarmConditionState reports the current state of an active alarm in a
|
||||||
|
// ConditionRefresh snapshot.
|
||||||
|
type AlarmConditionState = pb.AlarmConditionState
|
||||||
|
|
||||||
|
// StreamAlarmsClient is the generated server-streaming client for the
|
||||||
|
// StreamAlarms RPC.
|
||||||
|
type StreamAlarmsClient = pb.MxAccessGateway_StreamAlarmsClient
|
||||||
|
|
||||||
|
// Enumerations from the generated contract re-exported for client callers.
|
||||||
|
type (
|
||||||
|
// MxCommandKind discriminates which MXAccess command an MxCommand carries.
|
||||||
|
MxCommandKind = pb.MxCommandKind
|
||||||
|
// MxDataType is the MXAccess data type tag on values and arrays.
|
||||||
|
MxDataType = pb.MxDataType
|
||||||
|
// MxEventFamily groups MXAccess events by source category.
|
||||||
|
MxEventFamily = pb.MxEventFamily
|
||||||
|
// MxStatusCategory classifies MXSTATUS_PROXY entries.
|
||||||
|
MxStatusCategory = pb.MxStatusCategory
|
||||||
|
// MxStatusSource identifies the originator of a status entry.
|
||||||
|
MxStatusSource = pb.MxStatusSource
|
||||||
|
// ProtocolStatusCode enumerates gateway-level status codes.
|
||||||
|
ProtocolStatusCode = pb.ProtocolStatusCode
|
||||||
|
// SessionState enumerates gateway session lifecycle states.
|
||||||
|
SessionState = pb.SessionState
|
||||||
|
)
|
||||||
|
|
||||||
|
// MXAccess command kind, data type, and protocol status constants surfaced
|
||||||
|
// from the generated contract.
|
||||||
|
const (
|
||||||
|
// CommandKindRegister selects the MXAccess Register command.
|
||||||
|
CommandKindRegister = pb.MxCommandKind_MX_COMMAND_KIND_REGISTER
|
||||||
|
// CommandKindUnregister selects the MXAccess Unregister command.
|
||||||
|
CommandKindUnregister = pb.MxCommandKind_MX_COMMAND_KIND_UNREGISTER
|
||||||
|
// CommandKindAddItem selects the MXAccess AddItem command.
|
||||||
|
CommandKindAddItem = pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM
|
||||||
|
// CommandKindAddItem2 selects the MXAccess AddItem2 command.
|
||||||
|
CommandKindAddItem2 = pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM2
|
||||||
|
// CommandKindRemoveItem selects the MXAccess RemoveItem command.
|
||||||
|
CommandKindRemoveItem = pb.MxCommandKind_MX_COMMAND_KIND_REMOVE_ITEM
|
||||||
|
// CommandKindAdvise selects the MXAccess Advise command.
|
||||||
|
CommandKindAdvise = pb.MxCommandKind_MX_COMMAND_KIND_ADVISE
|
||||||
|
// CommandKindUnAdvise selects the MXAccess UnAdvise command.
|
||||||
|
CommandKindUnAdvise = pb.MxCommandKind_MX_COMMAND_KIND_UN_ADVISE
|
||||||
|
// CommandKindAddItemBulk selects the AddItem bulk command.
|
||||||
|
CommandKindAddItemBulk = pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM_BULK
|
||||||
|
// CommandKindAdviseItemBulk selects the Advise bulk command.
|
||||||
|
CommandKindAdviseItemBulk = pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_ITEM_BULK
|
||||||
|
// CommandKindRemoveItemBulk selects the RemoveItem bulk command.
|
||||||
|
CommandKindRemoveItemBulk = pb.MxCommandKind_MX_COMMAND_KIND_REMOVE_ITEM_BULK
|
||||||
|
// CommandKindUnAdviseItemBulk selects the UnAdvise bulk command.
|
||||||
|
CommandKindUnAdviseItemBulk = pb.MxCommandKind_MX_COMMAND_KIND_UN_ADVISE_ITEM_BULK
|
||||||
|
// CommandKindSubscribeBulk selects the AddItem+Advise combined bulk command.
|
||||||
|
CommandKindSubscribeBulk = pb.MxCommandKind_MX_COMMAND_KIND_SUBSCRIBE_BULK
|
||||||
|
// CommandKindUnsubscribeBulk selects the UnAdvise+RemoveItem combined bulk command.
|
||||||
|
CommandKindUnsubscribeBulk = pb.MxCommandKind_MX_COMMAND_KIND_UNSUBSCRIBE_BULK
|
||||||
|
// CommandKindWrite selects the MXAccess Write command.
|
||||||
|
CommandKindWrite = pb.MxCommandKind_MX_COMMAND_KIND_WRITE
|
||||||
|
// CommandKindWrite2 selects the MXAccess Write2 command.
|
||||||
|
CommandKindWrite2 = pb.MxCommandKind_MX_COMMAND_KIND_WRITE2
|
||||||
|
// CommandKindWriteBulk selects the bulk Write command.
|
||||||
|
CommandKindWriteBulk = pb.MxCommandKind_MX_COMMAND_KIND_WRITE_BULK
|
||||||
|
// CommandKindWrite2Bulk selects the bulk Write2 (timestamped) command.
|
||||||
|
CommandKindWrite2Bulk = pb.MxCommandKind_MX_COMMAND_KIND_WRITE2_BULK
|
||||||
|
// CommandKindWriteSecuredBulk selects the bulk WriteSecured command.
|
||||||
|
CommandKindWriteSecuredBulk = pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED_BULK
|
||||||
|
// CommandKindWriteSecured2Bulk selects the bulk WriteSecured2 (timestamped) command.
|
||||||
|
CommandKindWriteSecured2Bulk = pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED2_BULK
|
||||||
|
// CommandKindReadBulk selects the bulk Read command (cached-or-snapshot per tag).
|
||||||
|
CommandKindReadBulk = pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK
|
||||||
|
|
||||||
|
// DataTypeUnknown denotes an unrecognized MXAccess data type.
|
||||||
|
DataTypeUnknown = pb.MxDataType_MX_DATA_TYPE_UNKNOWN
|
||||||
|
// DataTypeBoolean denotes an MXAccess Boolean value.
|
||||||
|
DataTypeBoolean = pb.MxDataType_MX_DATA_TYPE_BOOLEAN
|
||||||
|
// DataTypeInteger denotes an MXAccess Integer value.
|
||||||
|
DataTypeInteger = pb.MxDataType_MX_DATA_TYPE_INTEGER
|
||||||
|
// DataTypeFloat denotes an MXAccess Float (single precision) value.
|
||||||
|
DataTypeFloat = pb.MxDataType_MX_DATA_TYPE_FLOAT
|
||||||
|
// DataTypeDouble denotes an MXAccess Double (double precision) value.
|
||||||
|
DataTypeDouble = pb.MxDataType_MX_DATA_TYPE_DOUBLE
|
||||||
|
// DataTypeString denotes an MXAccess String value.
|
||||||
|
DataTypeString = pb.MxDataType_MX_DATA_TYPE_STRING
|
||||||
|
// DataTypeTime denotes an MXAccess timestamp value.
|
||||||
|
DataTypeTime = pb.MxDataType_MX_DATA_TYPE_TIME
|
||||||
|
|
||||||
|
// ProtocolStatusOK indicates the gateway processed the request successfully.
|
||||||
|
ProtocolStatusOK = pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK
|
||||||
|
// ProtocolStatusMxAccessFailure indicates the worker reported an MXAccess failure.
|
||||||
|
ProtocolStatusMxAccessFailure = pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_MXACCESS_FAILURE
|
||||||
|
)
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BoolValue builds an MXAccess Boolean value.
|
||||||
|
func BoolValue(value bool) *MxValue {
|
||||||
|
return &pb.MxValue{
|
||||||
|
DataType: pb.MxDataType_MX_DATA_TYPE_BOOLEAN,
|
||||||
|
VariantType: "VT_BOOL",
|
||||||
|
Kind: &pb.MxValue_BoolValue{BoolValue: value},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Int32Value builds an MXAccess Int32 value.
|
||||||
|
func Int32Value(value int32) *MxValue {
|
||||||
|
return &pb.MxValue{
|
||||||
|
DataType: pb.MxDataType_MX_DATA_TYPE_INTEGER,
|
||||||
|
VariantType: "VT_I4",
|
||||||
|
Kind: &pb.MxValue_Int32Value{Int32Value: value},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Int64Value builds an MXAccess Int64 value.
|
||||||
|
func Int64Value(value int64) *MxValue {
|
||||||
|
return &pb.MxValue{
|
||||||
|
DataType: pb.MxDataType_MX_DATA_TYPE_INTEGER,
|
||||||
|
VariantType: "VT_I8",
|
||||||
|
Kind: &pb.MxValue_Int64Value{Int64Value: value},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FloatValue builds an MXAccess Float value.
|
||||||
|
func FloatValue(value float32) *MxValue {
|
||||||
|
return &pb.MxValue{
|
||||||
|
DataType: pb.MxDataType_MX_DATA_TYPE_FLOAT,
|
||||||
|
VariantType: "VT_R4",
|
||||||
|
Kind: &pb.MxValue_FloatValue{FloatValue: value},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DoubleValue builds an MXAccess Double value.
|
||||||
|
func DoubleValue(value float64) *MxValue {
|
||||||
|
return &pb.MxValue{
|
||||||
|
DataType: pb.MxDataType_MX_DATA_TYPE_DOUBLE,
|
||||||
|
VariantType: "VT_R8",
|
||||||
|
Kind: &pb.MxValue_DoubleValue{DoubleValue: value},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StringValue builds an MXAccess String value.
|
||||||
|
func StringValue(value string) *MxValue {
|
||||||
|
return &pb.MxValue{
|
||||||
|
DataType: pb.MxDataType_MX_DATA_TYPE_STRING,
|
||||||
|
VariantType: "VT_BSTR",
|
||||||
|
Kind: &pb.MxValue_StringValue{StringValue: value},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimestampValue builds an MXAccess timestamp value from a Go time.
|
||||||
|
func TimestampValue(value time.Time) *MxValue {
|
||||||
|
return &pb.MxValue{
|
||||||
|
DataType: pb.MxDataType_MX_DATA_TYPE_TIME,
|
||||||
|
VariantType: "VT_DATE",
|
||||||
|
Kind: &pb.MxValue_TimestampValue{TimestampValue: timestamppb.New(value)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NativeValue converts a protobuf MxValue to the closest Go representation
|
||||||
|
// without discarding raw fallback data.
|
||||||
|
func NativeValue(value *MxValue) (any, error) {
|
||||||
|
if value == nil || value.GetIsNull() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch kind := value.GetKind().(type) {
|
||||||
|
case *pb.MxValue_BoolValue:
|
||||||
|
return kind.BoolValue, nil
|
||||||
|
case *pb.MxValue_Int32Value:
|
||||||
|
return kind.Int32Value, nil
|
||||||
|
case *pb.MxValue_Int64Value:
|
||||||
|
return kind.Int64Value, nil
|
||||||
|
case *pb.MxValue_FloatValue:
|
||||||
|
return kind.FloatValue, nil
|
||||||
|
case *pb.MxValue_DoubleValue:
|
||||||
|
return kind.DoubleValue, nil
|
||||||
|
case *pb.MxValue_StringValue:
|
||||||
|
return kind.StringValue, nil
|
||||||
|
case *pb.MxValue_TimestampValue:
|
||||||
|
if kind.TimestampValue == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return kind.TimestampValue.AsTime(), nil
|
||||||
|
case *pb.MxValue_ArrayValue:
|
||||||
|
return NativeArray(kind.ArrayValue)
|
||||||
|
case *pb.MxValue_RawValue:
|
||||||
|
return append([]byte(nil), kind.RawValue...), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("mxgateway: unsupported value kind %T", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NativeArray converts a protobuf MxArray to the closest Go slice
|
||||||
|
// representation.
|
||||||
|
func NativeArray(array *MxArray) (any, error) {
|
||||||
|
if array == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch values := array.GetValues().(type) {
|
||||||
|
case *pb.MxArray_BoolValues:
|
||||||
|
return append([]bool(nil), values.BoolValues.GetValues()...), nil
|
||||||
|
case *pb.MxArray_Int32Values:
|
||||||
|
return append([]int32(nil), values.Int32Values.GetValues()...), nil
|
||||||
|
case *pb.MxArray_Int64Values:
|
||||||
|
return append([]int64(nil), values.Int64Values.GetValues()...), nil
|
||||||
|
case *pb.MxArray_FloatValues:
|
||||||
|
return append([]float32(nil), values.FloatValues.GetValues()...), nil
|
||||||
|
case *pb.MxArray_DoubleValues:
|
||||||
|
return append([]float64(nil), values.DoubleValues.GetValues()...), nil
|
||||||
|
case *pb.MxArray_StringValues:
|
||||||
|
return append([]string(nil), values.StringValues.GetValues()...), nil
|
||||||
|
case *pb.MxArray_TimestampValues:
|
||||||
|
result := make([]time.Time, 0, len(values.TimestampValues.GetValues()))
|
||||||
|
for _, value := range values.TimestampValues.GetValues() {
|
||||||
|
if value == nil {
|
||||||
|
result = append(result, time.Time{})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, value.AsTime())
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
case *pb.MxArray_RawValues:
|
||||||
|
rawValues := values.RawValues.GetValues()
|
||||||
|
result := make([][]byte, 0, len(rawValues))
|
||||||
|
for _, value := range rawValues {
|
||||||
|
result = append(result, append([]byte(nil), value...))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("mxgateway: unsupported array value kind %T", values)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ClientVersion identifies this Go client scaffold before package releases
|
||||||
|
// assign semantic versions.
|
||||||
|
ClientVersion = "0.1.0-dev"
|
||||||
|
|
||||||
|
// GatewayProtocolVersion matches GatewayContractInfo.GatewayProtocolVersion
|
||||||
|
// in the shared .NET contracts.
|
||||||
|
GatewayProtocolVersion uint32 = 3
|
||||||
|
|
||||||
|
// WorkerProtocolVersion matches GatewayContractInfo.WorkerProtocolVersion
|
||||||
|
// and is exposed for fake-worker and parity tests.
|
||||||
|
WorkerProtocolVersion uint32 = 1
|
||||||
|
)
|
||||||
@@ -5,8 +5,8 @@
|
|||||||
Provide a Java client library for MXAccess Gateway, plus a test CLI and unit
|
Provide a Java client library for MXAccess Gateway, plus a test CLI and unit
|
||||||
tests. The Java client should work for JVM services and operator tooling.
|
tests. The Java client should work for JVM services and operator tooling.
|
||||||
|
|
||||||
Follow the [Java Style Guide](./style-guides/JavaStyleGuide.md) for handwritten
|
Follow the [Java Style Guide](../../docs/style-guides/JavaStyleGuide.md) for handwritten
|
||||||
code and the [Protobuf Style Guide](./style-guides/ProtobufStyleGuide.md) for
|
code and the [Protobuf Style Guide](../../docs/style-guides/ProtobufStyleGuide.md) for
|
||||||
generated contract inputs.
|
generated contract inputs.
|
||||||
|
|
||||||
## Build Layout
|
## Build Layout
|
||||||
@@ -17,6 +17,7 @@ Recommended Gradle multi-project layout:
|
|||||||
clients/java/
|
clients/java/
|
||||||
settings.gradle
|
settings.gradle
|
||||||
build.gradle
|
build.gradle
|
||||||
|
src/main/generated/
|
||||||
mxgateway-client/
|
mxgateway-client/
|
||||||
build.gradle
|
build.gradle
|
||||||
src/main/java/com/dohertylan/mxgateway/client/
|
src/main/java/com/dohertylan/mxgateway/client/
|
||||||
@@ -31,6 +32,7 @@ Alternative Maven layout is acceptable if the repo standardizes on Maven.
|
|||||||
Target Java:
|
Target Java:
|
||||||
|
|
||||||
- Java 21 recommended.
|
- Java 21 recommended.
|
||||||
|
- The Gradle scaffold uses the Java 21 toolchain for compilation and tests.
|
||||||
|
|
||||||
Expected dependencies:
|
Expected dependencies:
|
||||||
|
|
||||||
@@ -62,6 +64,12 @@ public final class MxGatewaySession implements AutoCloseable {
|
|||||||
public int addItem(int serverHandle, String item);
|
public int addItem(int serverHandle, String item);
|
||||||
public int addItem2(int serverHandle, String item, String context);
|
public int addItem2(int serverHandle, String item, String context);
|
||||||
public void advise(int serverHandle, int itemHandle);
|
public void advise(int serverHandle, int itemHandle);
|
||||||
|
public List<SubscribeResult> addItemBulk(int serverHandle, List<String> tagAddresses);
|
||||||
|
public List<SubscribeResult> adviseItemBulk(int serverHandle, List<Integer> itemHandles);
|
||||||
|
public List<SubscribeResult> removeItemBulk(int serverHandle, List<Integer> itemHandles);
|
||||||
|
public List<SubscribeResult> unAdviseItemBulk(int serverHandle, List<Integer> itemHandles);
|
||||||
|
public List<SubscribeResult> subscribeBulk(int serverHandle, List<String> tagAddresses);
|
||||||
|
public List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles);
|
||||||
public void write(int serverHandle, int itemHandle, MxValue value, int userId);
|
public void write(int serverHandle, int itemHandle, MxValue value, int userId);
|
||||||
public Iterator<MxEvent> streamEvents();
|
public Iterator<MxEvent> streamEvents();
|
||||||
public void streamEventsAsync(StreamObserver<MxEvent> observer);
|
public void streamEventsAsync(StreamObserver<MxEvent> observer);
|
||||||
@@ -189,3 +197,23 @@ Publish library and CLI separately:
|
|||||||
|
|
||||||
Generated protobuf code should be produced during the build from shared proto
|
Generated protobuf code should be produced during the build from shared proto
|
||||||
files and should not be hand-edited.
|
files and should not be hand-edited.
|
||||||
|
|
||||||
|
## Current Build
|
||||||
|
|
||||||
|
Run the Java scaffold checks from `clients/java`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
gradle test
|
||||||
|
```
|
||||||
|
|
||||||
|
The `mxgateway-client` project generates the gateway and worker protobuf/gRPC
|
||||||
|
bindings into `src/main/generated`, compiles the generated contracts, and runs
|
||||||
|
JUnit 5 tests. The `mxgateway-cli` project builds a Picocli-based `mxgw-java`
|
||||||
|
entry point for later command implementation.
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Client Libraries Detailed Design](../../docs/ClientLibrariesDesign.md)
|
||||||
|
- [Client Proto Generation](../../docs/ClientProtoGeneration.md)
|
||||||
|
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||||
|
- [Java Style Guide](../../docs/style-guides/JavaStyleGuide.md)
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
# Java Client
|
||||||
|
|
||||||
|
The Java client workspace contains the MXAccess Gateway client library,
|
||||||
|
generated protobuf/gRPC bindings, a Picocli test CLI project, and JUnit tests.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
clients/java/
|
||||||
|
settings.gradle
|
||||||
|
build.gradle
|
||||||
|
src/main/generated/
|
||||||
|
mxgateway-client/
|
||||||
|
mxgateway-cli/
|
||||||
|
```
|
||||||
|
|
||||||
|
`mxgateway-client` generates Java protobuf and gRPC sources from
|
||||||
|
`../../src/MxGateway.Contracts/Protos`. The Gradle protobuf plugin writes those
|
||||||
|
generated sources under `src/main/generated`, which matches the client proto
|
||||||
|
manifest in `../proto/proto-inputs.json`. Do not edit generated files by hand.
|
||||||
|
|
||||||
|
`mxgateway-client` exposes `MxGatewayClientOptions`, `MxGatewayClient`,
|
||||||
|
`MxGatewaySession`, value/status helpers, typed gateway exceptions, raw
|
||||||
|
generated stubs, and generated protobuf messages for parity tests.
|
||||||
|
|
||||||
|
`mxgateway-cli` depends on `mxgateway-client` and provides the `mxgw-java`
|
||||||
|
application entry point. The CLI supports version, session, command, event
|
||||||
|
streaming, write, and smoke-test commands with deterministic JSON output.
|
||||||
|
|
||||||
|
## Regenerating Protobuf Bindings
|
||||||
|
|
||||||
|
Run generation from `clients/java` after the shared `.proto` files or Java
|
||||||
|
output path changes:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
gradle :mxgateway-client:generateProto
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client Usage
|
||||||
|
|
||||||
|
Create a client with explicit transport and auth options:
|
||||||
|
|
||||||
|
```java
|
||||||
|
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
|
||||||
|
.endpoint("localhost:5000")
|
||||||
|
.apiKey(System.getenv("MXGATEWAY_API_KEY"))
|
||||||
|
.plaintext(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try (MxGatewayClient client = MxGatewayClient.connect(options);
|
||||||
|
MxGatewaySession session = client.openSession("java-client")) {
|
||||||
|
int serverHandle = session.register("java-client");
|
||||||
|
int itemHandle = session.addItem(serverHandle, "TestObject.TestInt");
|
||||||
|
session.advise(serverHandle, itemHandle);
|
||||||
|
session.write(serverHandle, itemHandle, MxValues.int32Value(123), 0);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `rawBlockingStub`, `rawFutureStub`, `rawAsyncStub`, `openSessionRaw`,
|
||||||
|
`closeSessionRaw`, `invoke`, and raw session helper methods when tests need the
|
||||||
|
underlying protobuf messages. `MxGatewayCommandException` and
|
||||||
|
`MxAccessException` preserve the raw `MxCommandReply` when the gateway returns a
|
||||||
|
data-bearing MXAccess failure.
|
||||||
|
|
||||||
|
`MxGatewaySession` exposes the full bulk family — `addItemBulk`,
|
||||||
|
`adviseItemBulk`, `removeItemBulk`, `unAdviseItemBulk`, `subscribeBulk`,
|
||||||
|
`unsubscribeBulk`, `writeBulk`, `write2Bulk`, `writeSecuredBulk`,
|
||||||
|
`writeSecured2Bulk`, and `readBulk`. Each carries one round-trip with a
|
||||||
|
`List<*Entry>` (or `List<String>` / `List<Integer>` for the legacy bulk
|
||||||
|
shapes) and returns `List<SubscribeResult>` / `List<BulkWriteResult>` /
|
||||||
|
`List<BulkReadResult>`; per-entry MXAccess failures populate
|
||||||
|
`wasSuccessful == false` and never throw. `readBulk` takes a per-tag
|
||||||
|
`timeoutMs` (0 = worker default) and returns cached `OnDataChange` values
|
||||||
|
when the tag is already advised (`wasCached == true`) without touching the
|
||||||
|
existing subscription.
|
||||||
|
|
||||||
|
`openSession` verifies the gateway's reported `gateway_protocol_version` against
|
||||||
|
the version this client was generated for and throws `MxGatewayException` on a
|
||||||
|
mismatch, so an incompatible client fails fast with a clear message instead of
|
||||||
|
issuing commands that fail downstream. A gateway that does not populate the
|
||||||
|
field is accepted unchanged.
|
||||||
|
|
||||||
|
`MxGatewaySession` implements `AutoCloseable`. The try-with-resources `close()`
|
||||||
|
performs a `CloseSession` network RPC but swallows (and logs) any failure of
|
||||||
|
that RPC so a close-time error never replaces the exception a try-with-resources
|
||||||
|
body is already propagating. Call `closeRaw()` explicitly when you need to
|
||||||
|
observe the close result or handle a close-time failure.
|
||||||
|
|
||||||
|
`MxGatewayClient` and `GalaxyRepositoryClient` implement `AutoCloseable`. For a
|
||||||
|
client that owns its channel (built with `connect`), the try-with-resources
|
||||||
|
`close()` shuts the channel down and waits up to the configured
|
||||||
|
`shutdownTimeout` (default 10 s, independent of `connectTimeout`) for
|
||||||
|
termination, forcibly shutting it down on timeout, so in-flight calls and
|
||||||
|
Netty event-loop threads are not left running after the block exits. If the
|
||||||
|
calling thread is interrupted while waiting, the channel is forcibly shut down
|
||||||
|
and the interrupt flag is restored. `closeAndAwaitTermination()` does the same
|
||||||
|
but throws `InterruptedException` for callers that want a checked,
|
||||||
|
blocking-aware shutdown. `close()` is a no-op for a caller-managed channel.
|
||||||
|
|
||||||
|
`MxEventStream` implements `Iterator<MxEvent>` and `AutoCloseable`. Closing it
|
||||||
|
cancels the underlying gRPC stream. Canceling or timing out a Java client call
|
||||||
|
only stops the client from waiting; it does not abort an in-flight MXAccess COM
|
||||||
|
call on the worker STA. Closing an `MxEventStream` *before* the gRPC call has
|
||||||
|
attached its observer (a real race when callers cancel immediately after
|
||||||
|
subscribing) is safe — the close is replayed in the observer's `beforeStart`
|
||||||
|
and the underlying call is cancelled, matching `DeployEventStream` behaviour.
|
||||||
|
The event stream uses gRPC's default auto-inbound flow control with a fixed
|
||||||
|
1024-element buffer and no client-side flow control: this is the gateway's
|
||||||
|
documented fail-fast event-backpressure model, so a consumer that stalls long
|
||||||
|
enough to fill the buffer triggers an overflow that cancels the subscription
|
||||||
|
and surfaces an `MxGatewayException` from the next `next()` call. Drain events
|
||||||
|
promptly and be prepared to resubscribe with a resume cursor.
|
||||||
|
|
||||||
|
Cancellation of `CompletableFuture` results from `openSessionAsync`,
|
||||||
|
`invokeAsync`, `acknowledgeAlarmAsync`, `getLastDeployTimeAsync`,
|
||||||
|
`testConnectionAsync`, and `discoverHierarchyAsync` forwards to the underlying
|
||||||
|
gRPC call: calling `cancel(true)` on the returned future aborts the in-flight
|
||||||
|
RPC instead of merely detaching the future from its result.
|
||||||
|
|
||||||
|
## Galaxy Repository Browse
|
||||||
|
|
||||||
|
The Galaxy Repository service is a separate metadata-only gRPC service exposed
|
||||||
|
by the gateway. It lets clients enumerate the deployed Galaxy object hierarchy
|
||||||
|
and the dynamic attributes on each object so they know which tag references to
|
||||||
|
subscribe to via the MXAccess Gateway service. It uses the same API-key auth as
|
||||||
|
the gateway and requires the `metadata:read` scope.
|
||||||
|
|
||||||
|
`GalaxyRepositoryClient` mirrors the `MxGatewayClient` pattern (caller-managed
|
||||||
|
or owned channel, `MxGatewayClientOptions`, blocking + async variants). Three
|
||||||
|
RPCs are exposed:
|
||||||
|
|
||||||
|
```java
|
||||||
|
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
|
||||||
|
.endpoint("localhost:5000")
|
||||||
|
.apiKey(System.getenv("MXGATEWAY_API_KEY"))
|
||||||
|
.plaintext(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try (GalaxyRepositoryClient galaxy = GalaxyRepositoryClient.connect(options)) {
|
||||||
|
boolean ok = galaxy.testConnection();
|
||||||
|
Optional<Instant> lastDeploy = galaxy.getLastDeployTime();
|
||||||
|
List<GalaxyObject> hierarchy = galaxy.discoverHierarchy();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`getLastDeployTime` returns `Optional.empty()` when the server reports
|
||||||
|
`present=false`. `discoverHierarchy` returns the generated `GalaxyObject` proto
|
||||||
|
messages directly so callers can read all fields (including the nested
|
||||||
|
`GalaxyAttribute` list) without an extra DTO layer.
|
||||||
|
|
||||||
|
The CLI exposes matching subcommands: `galaxy-test`, `galaxy-deploy-time`,
|
||||||
|
`galaxy-discover`, and `galaxy-watch`. They take the same `--endpoint`,
|
||||||
|
`--api-key-env`, `--plaintext`, `--ca-file`, `--server-name-override`,
|
||||||
|
`--timeout`, and `--json` options as the gateway commands.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
gradle :mxgateway-cli:run --args="galaxy-test --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
|
||||||
|
gradle :mxgateway-cli:run --args="galaxy-deploy-time --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
|
||||||
|
gradle :mxgateway-cli:run --args="galaxy-discover --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Watching deploy events
|
||||||
|
|
||||||
|
`GalaxyRepository.WatchDeployEvents` is a server-streaming RPC: the gateway
|
||||||
|
sends a bootstrap `DeployEvent` immediately on subscribe and then one event
|
||||||
|
each time it observes a new `galaxy.time_of_last_deploy`. The `sequence` field
|
||||||
|
is monotonic per server start; gaps mean the per-subscriber buffer dropped
|
||||||
|
older events because the consumer was too slow.
|
||||||
|
|
||||||
|
The client exposes both an iterator-style adaptor over the async stub and an
|
||||||
|
observer-callback variant. Both honour the channel-level `streamTimeout`.
|
||||||
|
|
||||||
|
```java
|
||||||
|
try (GalaxyRepositoryClient galaxy = GalaxyRepositoryClient.connect(options);
|
||||||
|
DeployEventStream events = galaxy.watchDeployEvents(/* lastSeenDeployTime */ null)) {
|
||||||
|
while (events.hasNext()) {
|
||||||
|
DeployEvent event = events.next();
|
||||||
|
// event.getSequence(), event.getObservedAt(),
|
||||||
|
// event.getTimeOfLastDeploy() / getTimeOfLastDeployPresent(),
|
||||||
|
// event.getObjectCount(), event.getAttributeCount()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass an `Instant` for `lastSeenDeployTime` to suppress the bootstrap event when
|
||||||
|
the cached deploy time matches what the caller already has. `DeployEventStream`
|
||||||
|
implements `Iterator<DeployEvent>` and `AutoCloseable`; closing it cancels the
|
||||||
|
underlying gRPC call.
|
||||||
|
|
||||||
|
For callback delivery (e.g. when the consumer wants to drive a queue or
|
||||||
|
reactive pipeline), use the async variant:
|
||||||
|
|
||||||
|
```java
|
||||||
|
DeployEventSubscription subscription = galaxy.watchDeployEventsAsync(
|
||||||
|
lastSeen,
|
||||||
|
new StreamObserver<>() {
|
||||||
|
@Override public void onNext(DeployEvent value) { /* ... */ }
|
||||||
|
@Override public void onError(Throwable t) { /* ... */ }
|
||||||
|
@Override public void onCompleted() { /* ... */ }
|
||||||
|
});
|
||||||
|
// later:
|
||||||
|
subscription.cancel(); // or subscription.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
The matching CLI subcommand streams events until cancelled (Ctrl+C) and prints
|
||||||
|
one line per event in text mode or one JSON object per event with `--json`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
gradle :mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
|
||||||
|
gradle :mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --last-seen-deploy-time 2026-04-28T18:30:00Z --limit 5"
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI Usage
|
||||||
|
|
||||||
|
Run the CLI through Gradle:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
gradle :mxgateway-cli:run --args="version --json"
|
||||||
|
gradle :mxgateway-cli:run --args="open-session --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --client-session-name java-cli --json"
|
||||||
|
gradle :mxgateway-cli:run --args="register --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --client-name java-cli --json"
|
||||||
|
gradle :mxgateway-cli:run --args="add-item --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item TestObject.TestInt --json"
|
||||||
|
gradle :mxgateway-cli:run --args="advise --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --json"
|
||||||
|
gradle :mxgateway-cli:run --args="write --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --json"
|
||||||
|
gradle :mxgateway-cli:run --args="stream-events --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --limit 1 --json"
|
||||||
|
gradle :mxgateway-cli:run --args="smoke --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --item TestObject.TestInt --json"
|
||||||
|
```
|
||||||
|
|
||||||
|
The CLI accepts `--api-key`, `--api-key-env`, `--plaintext`, `--ca-file`,
|
||||||
|
`--server-name-override`, `--timeout`, and `--json` on gateway commands. JSON
|
||||||
|
output redacts API keys.
|
||||||
|
|
||||||
|
Use TLS options for a secured gateway:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
gradle :mxgateway-cli:run --args="smoke --endpoint mxgateway.example.local:5001 --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item TestObject.TestInt --json"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build And Test
|
||||||
|
|
||||||
|
Run the Java checks from `clients/java`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
gradle test
|
||||||
|
```
|
||||||
|
|
||||||
|
The build uses the Java 21 Gradle toolchain, compiles generated protobuf/gRPC
|
||||||
|
code, and runs JUnit 5 tests for the client wrapper, shared behavior fixtures,
|
||||||
|
in-process gRPC behavior, stream cancellation, and CLI parser/output behavior.
|
||||||
|
|
||||||
|
## Packaging
|
||||||
|
|
||||||
|
Create local library and CLI artifacts from `clients/java`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
gradle :mxgateway-client:jar :mxgateway-cli:installDist
|
||||||
|
```
|
||||||
|
|
||||||
|
The library jar is under `mxgateway-client/build/libs`. The installed CLI
|
||||||
|
distribution is under `mxgateway-cli/build/install/mxgateway-cli`.
|
||||||
|
|
||||||
|
## Integration Checks
|
||||||
|
|
||||||
|
Run live checks only when a gateway and MXAccess-backed worker are available:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:MXGATEWAY_INTEGRATION = '1'
|
||||||
|
$env:MXGATEWAY_ENDPOINT = 'localhost:5000'
|
||||||
|
$env:MXGATEWAY_API_KEY = '<gateway-api-key>'
|
||||||
|
$env:MXGATEWAY_TEST_ITEM = 'TestObject.TestInt'
|
||||||
|
gradle :mxgateway-cli:run --args="smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||||
|
- [Client Proto Generation](../../docs/ClientProtoGeneration.md)
|
||||||
|
- [Java Client Detailed Design](./JavaClientDesign.md)
|
||||||
|
- [Java Style Guide](../../docs/style-guides/JavaStyleGuide.md)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
plugins {
|
||||||
|
id 'base'
|
||||||
|
}
|
||||||
|
|
||||||
|
ext {
|
||||||
|
guavaVersion = '33.5.0-jre'
|
||||||
|
gsonVersion = '2.13.2'
|
||||||
|
grpcVersion = '1.76.0'
|
||||||
|
junitVersion = '5.14.1'
|
||||||
|
picocliVersion = '4.7.7'
|
||||||
|
protobufVersion = '4.33.1'
|
||||||
|
}
|
||||||
|
|
||||||
|
subprojects {
|
||||||
|
group = 'com.dohertylan.mxgateway'
|
||||||
|
version = '0.1.0'
|
||||||
|
|
||||||
|
pluginManager.withPlugin('java') {
|
||||||
|
java {
|
||||||
|
toolchain {
|
||||||
|
languageVersion = JavaLanguageVersion.of(21)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType(JavaCompile).configureEach {
|
||||||
|
options.encoding = 'UTF-8'
|
||||||
|
options.release = 21
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType(Test).configureEach {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
testImplementation platform("org.junit:junit-bom:${junitVersion}")
|
||||||
|
testImplementation 'org.junit.jupiter:junit-jupiter'
|
||||||
|
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
plugins {
|
||||||
|
id 'application'
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation project(':mxgateway-client')
|
||||||
|
implementation "com.google.protobuf:protobuf-java-util:${protobufVersion}"
|
||||||
|
implementation "info.picocli:picocli:${picocliVersion}"
|
||||||
|
}
|
||||||
|
|
||||||
|
application {
|
||||||
|
mainClass = 'com.dohertylan.mxgateway.cli.MxGatewayCli'
|
||||||
|
}
|
||||||
+1855
File diff suppressed because it is too large
Load Diff
+836
@@ -0,0 +1,836 @@
|
|||||||
|
package com.dohertylan.mxgateway.cli;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.PrintWriter;
|
||||||
|
import java.io.StringWriter;
|
||||||
|
import com.dohertylan.mxgateway.client.MxGatewayAlarmFeedSubscription;
|
||||||
|
import io.grpc.stub.StreamObserver;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.AddItemReply;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.AlarmConditionState;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.AlarmTransitionKind;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.BulkReadResult;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.BulkWriteResult;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.MxCommandKind;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.OnAlarmTransitionEvent;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.RegisterReply;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.SessionState;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.Write2BulkEntry;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.WriteBulkEntry;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.WriteSecured2BulkEntry;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.WriteSecuredBulkEntry;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class MxGatewayCliTests {
|
||||||
|
@Test
|
||||||
|
void versionCommandPrintsProtocolVersions() {
|
||||||
|
CliRun run = execute(new FakeClientFactory(), "version");
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertEquals("", run.errors());
|
||||||
|
assertTrue(run.output().contains("mxgateway-java 0.1.0"));
|
||||||
|
assertTrue(run.output().contains("gatewayProtocolVersion=3"));
|
||||||
|
assertTrue(run.output().contains("workerProtocolVersion=1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void versionCommandPrintsJson() {
|
||||||
|
CliRun run = execute(new FakeClientFactory(), "version", "--json");
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertTrue(run.output().contains("\"clientVersion\":\"0.1.0\""));
|
||||||
|
assertTrue(run.output().contains("\"gatewayProtocolVersion\":3"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void openSessionJsonRedactsApiKey() {
|
||||||
|
CliRun run = execute(
|
||||||
|
new FakeClientFactory(),
|
||||||
|
"open-session",
|
||||||
|
"--endpoint",
|
||||||
|
"localhost:5000",
|
||||||
|
"--api-key",
|
||||||
|
"mxgw_visible_secret",
|
||||||
|
"--plaintext",
|
||||||
|
"--client-session-name",
|
||||||
|
"java-cli",
|
||||||
|
"--json");
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertTrue(run.output().contains("\"command\":\"open-session\""));
|
||||||
|
assertTrue(run.output().contains("\"sessionId\":\"session-cli\""));
|
||||||
|
// Only the non-secret mxgw_<key-id>_ prefix survives; the secret is fully masked.
|
||||||
|
assertTrue(run.output().contains("mxgw_visible_***"));
|
||||||
|
assertFalse(run.output().contains("visible_secret"));
|
||||||
|
assertFalse(run.output().contains("cret"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void writeBuildsTypedValueFromParserOptions() {
|
||||||
|
FakeClientFactory factory = new FakeClientFactory();
|
||||||
|
CliRun run = execute(
|
||||||
|
factory,
|
||||||
|
"write",
|
||||||
|
"--session-id",
|
||||||
|
"session-cli",
|
||||||
|
"--server-handle",
|
||||||
|
"12",
|
||||||
|
"--item-handle",
|
||||||
|
"34",
|
||||||
|
"--type",
|
||||||
|
"int32",
|
||||||
|
"--value",
|
||||||
|
"123",
|
||||||
|
"--json");
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertEquals(123, factory.client.session.lastWriteValue.getInt32Value());
|
||||||
|
assertTrue(run.output().contains("\"kind\":\"MX_COMMAND_KIND_WRITE\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void smokeCommandRunsOpenRegisterAddAdviseAndClose() {
|
||||||
|
FakeClientFactory factory = new FakeClientFactory();
|
||||||
|
CliRun run = execute(factory, "smoke", "--item", "TestObject.TestInt", "--json");
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertTrue(factory.client.session.registerCalled);
|
||||||
|
assertTrue(factory.client.session.addItemCalled);
|
||||||
|
assertTrue(factory.client.session.adviseCalled);
|
||||||
|
assertTrue(factory.client.closeCalled);
|
||||||
|
assertTrue(run.output().contains("\"serverHandle\":42"));
|
||||||
|
assertTrue(run.output().contains("\"itemHandle\":7"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void subscribeBulkCommandPrintsResults() {
|
||||||
|
CliRun run = execute(
|
||||||
|
new FakeClientFactory(),
|
||||||
|
"subscribe-bulk",
|
||||||
|
"--session-id",
|
||||||
|
"session-cli",
|
||||||
|
"--server-handle",
|
||||||
|
"42",
|
||||||
|
"--items",
|
||||||
|
"TestMachine_001.TestChangingInt,TestMachine_002.TestChangingInt",
|
||||||
|
"--json");
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertTrue(run.output().contains("\"command\":\"subscribe-bulk\""));
|
||||||
|
assertTrue(run.output().contains("\"itemHandle\":100"));
|
||||||
|
assertTrue(run.output().contains("\"tagAddress\":\"TestMachine_002.TestChangingInt\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deployEventSequenceRendersAsUnsignedForHighUint64() {
|
||||||
|
// Client.Java-020 regression: galaxy-watch text output now uses
|
||||||
|
// Long.toUnsignedString to format the proto uint64 sequence field, so
|
||||||
|
// values past 2^63 render as positive decimal strings instead of the
|
||||||
|
// negative signed-long interpretation the old "%d" produced.
|
||||||
|
long highUnsigned = -1L; // bit-pattern for 2^64 - 1, i.e. 18446744073709551615 unsigned
|
||||||
|
String text = String.format(
|
||||||
|
"seq=%s observed=%s deployTime=%s objects=%d attributes=%d",
|
||||||
|
Long.toUnsignedString(highUnsigned),
|
||||||
|
"2026-05-20T00:00:00Z",
|
||||||
|
"(none)",
|
||||||
|
0,
|
||||||
|
0);
|
||||||
|
|
||||||
|
assertTrue(text.contains("seq=18446744073709551615"), "expected unsigned rendering, got: " + text);
|
||||||
|
assertFalse(text.contains("seq=-1"), "must not render as signed -1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void streamEventsWorkerSequenceRendersAsUnsignedForHighUint64() {
|
||||||
|
// Client.Java-023 regression: stream-events text output now uses
|
||||||
|
// Long.toUnsignedString to format the proto uint64 worker_sequence
|
||||||
|
// field, mirroring the Client.Java-020 fix for DeployEvent.sequence.
|
||||||
|
long highUnsigned = -1L; // bit-pattern for 2^64 - 1, i.e. 18446744073709551615 unsigned
|
||||||
|
String text = String.format(
|
||||||
|
"%s %s",
|
||||||
|
Long.toUnsignedString(highUnsigned),
|
||||||
|
"MX_EVENT_FAMILY_DATA_CHANGE");
|
||||||
|
|
||||||
|
assertTrue(text.startsWith("18446744073709551615 "), "expected unsigned rendering, got: " + text);
|
||||||
|
assertFalse(text.startsWith("-1 "), "must not render as signed -1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unsubscribeBulkCommandPrintsResults() {
|
||||||
|
CliRun run = execute(
|
||||||
|
new FakeClientFactory(),
|
||||||
|
"unsubscribe-bulk",
|
||||||
|
"--session-id",
|
||||||
|
"session-cli",
|
||||||
|
"--server-handle",
|
||||||
|
"42",
|
||||||
|
"--item-handles",
|
||||||
|
"100,101",
|
||||||
|
"--json");
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertTrue(run.output().contains("\"command\":\"unsubscribe-bulk\""));
|
||||||
|
assertTrue(run.output().contains("\"itemHandle\":101"));
|
||||||
|
assertTrue(run.output().contains("\"wasSuccessful\":true"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Client.Java-026: CLI-level coverage for bulk subcommands ----
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readBulkCommandForwardsTimeoutAndPrintsResults() {
|
||||||
|
FakeClientFactory factory = new FakeClientFactory();
|
||||||
|
CliRun run = execute(
|
||||||
|
factory,
|
||||||
|
"read-bulk",
|
||||||
|
"--session-id",
|
||||||
|
"session-cli",
|
||||||
|
"--server-handle",
|
||||||
|
"42",
|
||||||
|
"--items",
|
||||||
|
"TestMachine_001.TestChangingInt,TestMachine_002.TestChangingInt",
|
||||||
|
"--timeout-ms",
|
||||||
|
"750",
|
||||||
|
"--json");
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertEquals(750, factory.client.session.lastReadBulkTimeoutMs);
|
||||||
|
assertEquals(2, factory.client.session.lastReadBulkItems.size());
|
||||||
|
assertTrue(run.output().contains("\"command\":\"read-bulk\""));
|
||||||
|
assertTrue(run.output().contains("\"tagAddress\":\"TestMachine_001.TestChangingInt\""));
|
||||||
|
assertTrue(run.output().contains("\"itemHandle\":200"));
|
||||||
|
assertTrue(run.output().contains("\"wasCached\":true"));
|
||||||
|
assertTrue(run.output().contains("\"quality\":192"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void writeBulkCommandParsesTypedValuesAndPrintsResults() {
|
||||||
|
FakeClientFactory factory = new FakeClientFactory();
|
||||||
|
CliRun run = execute(
|
||||||
|
factory,
|
||||||
|
"write-bulk",
|
||||||
|
"--session-id",
|
||||||
|
"session-cli",
|
||||||
|
"--server-handle",
|
||||||
|
"42",
|
||||||
|
"--item-handles",
|
||||||
|
"100,101",
|
||||||
|
"--type",
|
||||||
|
"int32",
|
||||||
|
"--values",
|
||||||
|
"111,222",
|
||||||
|
"--user-id",
|
||||||
|
"5",
|
||||||
|
"--json");
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertEquals(2, factory.client.session.lastWriteBulkEntries.size());
|
||||||
|
assertEquals(111, factory.client.session.lastWriteBulkEntries.get(0).getValue().getInt32Value());
|
||||||
|
assertEquals(222, factory.client.session.lastWriteBulkEntries.get(1).getValue().getInt32Value());
|
||||||
|
assertEquals(5, factory.client.session.lastWriteBulkEntries.get(0).getUserId());
|
||||||
|
assertTrue(run.output().contains("\"command\":\"write-bulk\""));
|
||||||
|
assertTrue(run.output().contains("\"itemHandle\":100"));
|
||||||
|
assertTrue(run.output().contains("\"wasSuccessful\":true"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void write2BulkCommandForwardsTimestampAndPrintsResults() {
|
||||||
|
FakeClientFactory factory = new FakeClientFactory();
|
||||||
|
CliRun run = execute(
|
||||||
|
factory,
|
||||||
|
"write2-bulk",
|
||||||
|
"--session-id",
|
||||||
|
"session-cli",
|
||||||
|
"--server-handle",
|
||||||
|
"42",
|
||||||
|
"--item-handles",
|
||||||
|
"100",
|
||||||
|
"--type",
|
||||||
|
"string",
|
||||||
|
"--values",
|
||||||
|
"hello",
|
||||||
|
"--timestamp",
|
||||||
|
"2026-05-20T00:00:00Z",
|
||||||
|
"--json");
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertEquals(1, factory.client.session.lastWrite2BulkEntries.size());
|
||||||
|
assertEquals(
|
||||||
|
"hello",
|
||||||
|
factory.client.session.lastWrite2BulkEntries.get(0).getValue().getStringValue());
|
||||||
|
assertTrue(
|
||||||
|
factory.client.session.lastWrite2BulkEntries.get(0).hasTimestampValue(),
|
||||||
|
"expected timestampValue to be forwarded");
|
||||||
|
assertTrue(run.output().contains("\"command\":\"write2-bulk\""));
|
||||||
|
assertTrue(run.output().contains("\"itemHandle\":100"));
|
||||||
|
assertTrue(run.output().contains("\"wasSuccessful\":true"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void writeSecuredBulkCommandForwardsUserIdsAndPrintsResults() {
|
||||||
|
FakeClientFactory factory = new FakeClientFactory();
|
||||||
|
CliRun run = execute(
|
||||||
|
factory,
|
||||||
|
"write-secured-bulk",
|
||||||
|
"--session-id",
|
||||||
|
"session-cli",
|
||||||
|
"--server-handle",
|
||||||
|
"42",
|
||||||
|
"--item-handles",
|
||||||
|
"100",
|
||||||
|
"--type",
|
||||||
|
"int32",
|
||||||
|
"--values",
|
||||||
|
"9",
|
||||||
|
"--current-user-id",
|
||||||
|
"7",
|
||||||
|
"--verifier-user-id",
|
||||||
|
"8",
|
||||||
|
"--json");
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertEquals(1, factory.client.session.lastWriteSecuredBulkEntries.size());
|
||||||
|
assertEquals(7, factory.client.session.lastWriteSecuredBulkEntries.get(0).getCurrentUserId());
|
||||||
|
assertEquals(8, factory.client.session.lastWriteSecuredBulkEntries.get(0).getVerifierUserId());
|
||||||
|
assertEquals(9, factory.client.session.lastWriteSecuredBulkEntries.get(0).getValue().getInt32Value());
|
||||||
|
assertTrue(run.output().contains("\"command\":\"write-secured-bulk\""));
|
||||||
|
assertTrue(run.output().contains("\"wasSuccessful\":true"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void writeSecured2BulkCommandForwardsTimestampAndUserIdsAndPrintsResults() {
|
||||||
|
FakeClientFactory factory = new FakeClientFactory();
|
||||||
|
CliRun run = execute(
|
||||||
|
factory,
|
||||||
|
"write-secured2-bulk",
|
||||||
|
"--session-id",
|
||||||
|
"session-cli",
|
||||||
|
"--server-handle",
|
||||||
|
"42",
|
||||||
|
"--item-handles",
|
||||||
|
"100",
|
||||||
|
"--type",
|
||||||
|
"string",
|
||||||
|
"--values",
|
||||||
|
"value",
|
||||||
|
"--timestamp",
|
||||||
|
"2026-05-20T00:00:00Z",
|
||||||
|
"--current-user-id",
|
||||||
|
"7",
|
||||||
|
"--verifier-user-id",
|
||||||
|
"8",
|
||||||
|
"--json");
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertEquals(1, factory.client.session.lastWriteSecured2BulkEntries.size());
|
||||||
|
assertEquals(7, factory.client.session.lastWriteSecured2BulkEntries.get(0).getCurrentUserId());
|
||||||
|
assertEquals(8, factory.client.session.lastWriteSecured2BulkEntries.get(0).getVerifierUserId());
|
||||||
|
assertTrue(
|
||||||
|
factory.client.session.lastWriteSecured2BulkEntries.get(0).hasTimestampValue(),
|
||||||
|
"expected timestampValue to be forwarded");
|
||||||
|
assertTrue(run.output().contains("\"command\":\"write-secured2-bulk\""));
|
||||||
|
assertTrue(run.output().contains("\"wasSuccessful\":true"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void benchReadBulkCommandEmitsJsonSchemaKeys() {
|
||||||
|
// Short bench window (1 s steady, 0 s warmup) keeps the test fast; we assert
|
||||||
|
// the JSON schema rather than numeric values so the cross-language matrix
|
||||||
|
// (.NET / Go / Rust / Python) and the Java path agree on the output shape.
|
||||||
|
FakeClientFactory factory = new FakeClientFactory();
|
||||||
|
CliRun run = execute(
|
||||||
|
factory,
|
||||||
|
"bench-read-bulk",
|
||||||
|
"--duration-seconds",
|
||||||
|
"1",
|
||||||
|
"--warmup-seconds",
|
||||||
|
"0",
|
||||||
|
"--bulk-size",
|
||||||
|
"2",
|
||||||
|
"--tag-start",
|
||||||
|
"1",
|
||||||
|
"--tag-prefix",
|
||||||
|
"TestMachine_",
|
||||||
|
"--tag-attribute",
|
||||||
|
"TestChangingInt",
|
||||||
|
"--timeout-ms",
|
||||||
|
"100",
|
||||||
|
"--json");
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
String output = run.output();
|
||||||
|
assertTrue(output.contains("\"language\":\"java\""), output);
|
||||||
|
assertTrue(output.contains("\"command\":\"bench-read-bulk\""), output);
|
||||||
|
assertTrue(output.contains("\"bulkSize\":2"), output);
|
||||||
|
assertTrue(output.contains("\"durationSeconds\":1"), output);
|
||||||
|
assertTrue(output.contains("\"warmupSeconds\":0"), output);
|
||||||
|
assertTrue(output.contains("\"totalCalls\":"), output);
|
||||||
|
assertTrue(output.contains("\"successfulCalls\":"), output);
|
||||||
|
assertTrue(output.contains("\"failedCalls\":"), output);
|
||||||
|
assertTrue(output.contains("\"callsPerSecond\":"), output);
|
||||||
|
assertTrue(output.contains("\"latencyMs\":"), output);
|
||||||
|
assertTrue(output.contains("\"p50\":"), output);
|
||||||
|
assertTrue(output.contains("\"p95\":"), output);
|
||||||
|
assertTrue(output.contains("\"p99\":"), output);
|
||||||
|
assertTrue(output.contains("\"tags\":"), output);
|
||||||
|
// Bench tag synthesis: TestMachine_001.TestChangingInt, TestMachine_002.TestChangingInt.
|
||||||
|
assertTrue(output.contains("TestMachine_001.TestChangingInt"), output);
|
||||||
|
assertTrue(output.contains("TestMachine_002.TestChangingInt"), output);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- stream-alarms / acknowledge-alarm subcommands ----
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void streamAlarmsCommandForwardsFilterPrefixAndPrintsFeedMessages() {
|
||||||
|
FakeClientFactory factory = new FakeClientFactory();
|
||||||
|
CliRun run = execute(factory, "stream-alarms", "--filter-prefix", "Tank01");
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertEquals("Tank01", factory.client.lastStreamAlarmsRequest.getAlarmFilterPrefix());
|
||||||
|
String out = run.output();
|
||||||
|
assertTrue(out.contains("active-alarm Tank01.Level.HiHi"), out);
|
||||||
|
assertTrue(out.contains("snapshot-complete"), out);
|
||||||
|
assertTrue(out.contains("transition Tank01.Level.HiHi"), out);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void streamAlarmsCommandHonoursLimit() {
|
||||||
|
FakeClientFactory factory = new FakeClientFactory();
|
||||||
|
CliRun run = execute(factory, "stream-alarms", "--limit", "1");
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
long lines = run.output().lines().filter(line -> !line.isBlank()).count();
|
||||||
|
assertEquals(1, lines, "expected exactly one feed message with --limit 1, got: " + run.output());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void streamAlarmsCommandPrintsJson() {
|
||||||
|
FakeClientFactory factory = new FakeClientFactory();
|
||||||
|
CliRun run = execute(factory, "stream-alarms", "--json");
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertTrue(run.output().contains("\"activeAlarm\""), run.output());
|
||||||
|
assertTrue(run.output().contains("\"snapshotComplete\""), run.output());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acknowledgeAlarmCommandForwardsOptionsAndPrintsReply() {
|
||||||
|
FakeClientFactory factory = new FakeClientFactory();
|
||||||
|
CliRun run = execute(
|
||||||
|
factory,
|
||||||
|
"acknowledge-alarm",
|
||||||
|
"--reference",
|
||||||
|
"Tank01.Level.HiHi",
|
||||||
|
"--comment",
|
||||||
|
"checked",
|
||||||
|
"--operator",
|
||||||
|
"operator1",
|
||||||
|
"--json");
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertEquals("Tank01.Level.HiHi", factory.client.lastAcknowledgeAlarmRequest.getAlarmFullReference());
|
||||||
|
assertEquals("checked", factory.client.lastAcknowledgeAlarmRequest.getComment());
|
||||||
|
assertEquals("operator1", factory.client.lastAcknowledgeAlarmRequest.getOperatorUser());
|
||||||
|
assertTrue(run.output().contains("\"command\":\"acknowledge-alarm\""), run.output());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acknowledgeAlarmCommandRequiresReference() {
|
||||||
|
CliRun run = execute(new FakeClientFactory(), "acknowledge-alarm", "--comment", "checked");
|
||||||
|
|
||||||
|
assertFalse(run.exitCode() == 0, "expected non-zero exit without --reference");
|
||||||
|
assertTrue(run.errors().contains("--reference"), run.errors());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Client.Java-027: batch subcommand ----
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchCommandExecutesTwoCommandsAndEmitsEorAfterEach() {
|
||||||
|
String stdin = "version --json\nversion --json\n";
|
||||||
|
CliRun run = executeBatch(new FakeClientFactory(), stdin);
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
String out = run.output();
|
||||||
|
// Two EOR sentinels — one per input line.
|
||||||
|
int firstEor = out.indexOf(MxGatewayCli.BATCH_EOR);
|
||||||
|
int lastEor = out.lastIndexOf(MxGatewayCli.BATCH_EOR);
|
||||||
|
assertTrue(firstEor >= 0, "expected at least one EOR sentinel");
|
||||||
|
assertTrue(lastEor > firstEor, "expected two distinct EOR sentinels");
|
||||||
|
// Both results contain version JSON.
|
||||||
|
assertTrue(out.contains("\"clientVersion\""), out);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchCommandEmitsEorOnFailedCommand() {
|
||||||
|
// "open-session" without --endpoint / --api-key-env will fail against
|
||||||
|
// the FakeClientFactory (missing required option --session-id for
|
||||||
|
// close-session, for example). Use an unknown subcommand to provoke a
|
||||||
|
// picocli parse error which produces a non-zero exit code without
|
||||||
|
// hitting the gateway.
|
||||||
|
String stdin = "no-such-subcommand\nversion --json\n";
|
||||||
|
CliRun run = executeBatch(new FakeClientFactory(), stdin);
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
String out = run.output();
|
||||||
|
// Two EOR sentinels even though the first command failed.
|
||||||
|
int firstEor = out.indexOf(MxGatewayCli.BATCH_EOR);
|
||||||
|
int lastEor = out.lastIndexOf(MxGatewayCli.BATCH_EOR);
|
||||||
|
assertTrue(firstEor >= 0, "expected EOR after failed command");
|
||||||
|
assertTrue(lastEor > firstEor, "expected EOR after second (successful) command");
|
||||||
|
// The second command's result is present.
|
||||||
|
assertTrue(out.contains("\"clientVersion\""), out);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchCommandExitsZeroOnEmptyLine() {
|
||||||
|
// An empty line signals EOF-equivalent; loop exits immediately.
|
||||||
|
CliRun run = executeBatch(new FakeClientFactory(), "\n");
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchCommandExitsZeroOnActualEof() {
|
||||||
|
CliRun run = executeBatch(new FakeClientFactory(), "");
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchCommandDoesNotTerminateAfterFailedCommand() {
|
||||||
|
// Three lines: good, bad, good — all three EORs must appear and the
|
||||||
|
// third command must produce its output.
|
||||||
|
String stdin = "version --json\nno-such-subcommand\nversion --json\n";
|
||||||
|
CliRun run = executeBatch(new FakeClientFactory(), stdin);
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
String out = run.output();
|
||||||
|
long eorCount = out.lines()
|
||||||
|
.filter(l -> l.equals(MxGatewayCli.BATCH_EOR))
|
||||||
|
.count();
|
||||||
|
assertEquals(3, eorCount, "expected exactly 3 EOR sentinels, got: " + eorCount + "\nOutput:\n" + out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the CLI with {@code batch} as the subcommand, using the provided
|
||||||
|
* string as standard input content. Temporarily replaces {@link System#in}
|
||||||
|
* for the duration of the call.
|
||||||
|
*/
|
||||||
|
private static CliRun executeBatch(MxGatewayCli.MxGatewayCliClientFactory factory, String stdinContent) {
|
||||||
|
InputStream originalIn = System.in;
|
||||||
|
try {
|
||||||
|
System.setIn(new ByteArrayInputStream(stdinContent.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
return execute(factory, "batch");
|
||||||
|
} finally {
|
||||||
|
System.setIn(originalIn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CliRun execute(MxGatewayCli.MxGatewayCliClientFactory factory, String... args) {
|
||||||
|
StringWriter output = new StringWriter();
|
||||||
|
StringWriter errors = new StringWriter();
|
||||||
|
int exitCode = MxGatewayCli.execute(
|
||||||
|
factory,
|
||||||
|
new PrintWriter(output, true),
|
||||||
|
new PrintWriter(errors, true),
|
||||||
|
args);
|
||||||
|
return new CliRun(exitCode, output.toString(), errors.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private record CliRun(int exitCode, String output, String errors) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class FakeClientFactory implements MxGatewayCli.MxGatewayCliClientFactory {
|
||||||
|
private FakeClient client;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MxGatewayCli.MxGatewayCliClient connect(MxGatewayCli.CommonOptions options) {
|
||||||
|
client = new FakeClient(options.spec.commandLine().getOut());
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class FakeClient implements MxGatewayCli.MxGatewayCliClient {
|
||||||
|
private final PrintWriter out;
|
||||||
|
private final FakeSession session = new FakeSession();
|
||||||
|
private boolean closeCalled;
|
||||||
|
private AcknowledgeAlarmRequest lastAcknowledgeAlarmRequest;
|
||||||
|
private StreamAlarmsRequest lastStreamAlarmsRequest;
|
||||||
|
|
||||||
|
private FakeClient(PrintWriter out) {
|
||||||
|
this.out = out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PrintWriter out() {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public OpenSessionReply openSession(OpenSessionRequest request) {
|
||||||
|
return OpenSessionReply.newBuilder()
|
||||||
|
.setSessionId("session-cli")
|
||||||
|
.setProtocolStatus(ok())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CloseSessionReply closeSession(CloseSessionRequest request) {
|
||||||
|
closeCalled = true;
|
||||||
|
return CloseSessionReply.newBuilder()
|
||||||
|
.setSessionId(request.getSessionId())
|
||||||
|
.setFinalState(SessionState.SESSION_STATE_CLOSED)
|
||||||
|
.setProtocolStatus(ok())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MxGatewayCli.MxGatewayCliSession session(String sessionId) {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AcknowledgeAlarmReply acknowledgeAlarm(AcknowledgeAlarmRequest request) {
|
||||||
|
lastAcknowledgeAlarmRequest = request;
|
||||||
|
return AcknowledgeAlarmReply.newBuilder()
|
||||||
|
.setCorrelationId(request.getClientCorrelationId())
|
||||||
|
.setProtocolStatus(ok())
|
||||||
|
.setHresult(0)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MxGatewayAlarmFeedSubscription streamAlarms(
|
||||||
|
StreamAlarmsRequest request, StreamObserver<AlarmFeedMessage> observer) {
|
||||||
|
lastStreamAlarmsRequest = request;
|
||||||
|
// Replay a deterministic active-alarm snapshot, snapshot-complete
|
||||||
|
// sentinel, transition, then complete the feed so the CLI command
|
||||||
|
// drains a bounded stream without contacting a live gateway.
|
||||||
|
observer.onNext(AlarmFeedMessage.newBuilder()
|
||||||
|
.setActiveAlarm(ActiveAlarmSnapshot.newBuilder()
|
||||||
|
.setAlarmFullReference("Tank01.Level.HiHi")
|
||||||
|
.setCurrentState(AlarmConditionState.ALARM_CONDITION_STATE_ACTIVE)
|
||||||
|
.setSeverity(700))
|
||||||
|
.build());
|
||||||
|
observer.onNext(AlarmFeedMessage.newBuilder().setSnapshotComplete(true).build());
|
||||||
|
observer.onNext(AlarmFeedMessage.newBuilder()
|
||||||
|
.setTransition(OnAlarmTransitionEvent.newBuilder()
|
||||||
|
.setAlarmFullReference("Tank01.Level.HiHi")
|
||||||
|
.setTransitionKind(AlarmTransitionKind.ALARM_TRANSITION_KIND_ACKNOWLEDGE)
|
||||||
|
.setSeverity(700))
|
||||||
|
.build());
|
||||||
|
observer.onCompleted();
|
||||||
|
return new MxGatewayAlarmFeedSubscription();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class FakeSession implements MxGatewayCli.MxGatewayCliSession {
|
||||||
|
private boolean registerCalled;
|
||||||
|
private boolean addItemCalled;
|
||||||
|
private boolean adviseCalled;
|
||||||
|
private MxValue lastWriteValue;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int register(String clientName) {
|
||||||
|
registerCalled = true;
|
||||||
|
return 42;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MxCommandReply registerRaw(String clientName) {
|
||||||
|
registerCalled = true;
|
||||||
|
return MxCommandReply.newBuilder()
|
||||||
|
.setKind(MxCommandKind.MX_COMMAND_KIND_REGISTER)
|
||||||
|
.setProtocolStatus(ok())
|
||||||
|
.setRegister(RegisterReply.newBuilder().setServerHandle(42))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int addItem(int serverHandle, String itemDefinition) {
|
||||||
|
addItemCalled = true;
|
||||||
|
return 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MxCommandReply addItemRaw(int serverHandle, String itemDefinition) {
|
||||||
|
addItemCalled = true;
|
||||||
|
return MxCommandReply.newBuilder()
|
||||||
|
.setKind(MxCommandKind.MX_COMMAND_KIND_ADD_ITEM)
|
||||||
|
.setProtocolStatus(ok())
|
||||||
|
.setAddItem(AddItemReply.newBuilder().setItemHandle(7))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void advise(int serverHandle, int itemHandle) {
|
||||||
|
adviseCalled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MxCommandReply adviseRaw(int serverHandle, int itemHandle) {
|
||||||
|
adviseCalled = true;
|
||||||
|
return MxCommandReply.newBuilder()
|
||||||
|
.setKind(MxCommandKind.MX_COMMAND_KIND_ADVISE)
|
||||||
|
.setProtocolStatus(ok())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MxCommandReply writeRaw(int serverHandle, int itemHandle, MxValue value, int userId) {
|
||||||
|
lastWriteValue = value;
|
||||||
|
return MxCommandReply.newBuilder()
|
||||||
|
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE)
|
||||||
|
.setProtocolStatus(ok())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items) {
|
||||||
|
List<SubscribeResult> results = new ArrayList<>();
|
||||||
|
for (int index = 0; index < items.size(); index++) {
|
||||||
|
results.add(SubscribeResult.newBuilder()
|
||||||
|
.setServerHandle(serverHandle)
|
||||||
|
.setTagAddress(items.get(index))
|
||||||
|
.setItemHandle(100 + index)
|
||||||
|
.setWasSuccessful(true)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles) {
|
||||||
|
List<SubscribeResult> results = new ArrayList<>();
|
||||||
|
for (Integer itemHandle : itemHandles) {
|
||||||
|
results.add(SubscribeResult.newBuilder()
|
||||||
|
.setServerHandle(serverHandle)
|
||||||
|
.setItemHandle(itemHandle)
|
||||||
|
.setWasSuccessful(true)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recorded so tests can assert the CLI forwarded the parsed options through to
|
||||||
|
// the session interface. The bulk subcommands return at least one result so the
|
||||||
|
// JSON output assertions exercise the *Map serialisers in MxGatewayCli.
|
||||||
|
|
||||||
|
private int lastReadBulkTimeoutMs;
|
||||||
|
private List<String> lastReadBulkItems = new ArrayList<>();
|
||||||
|
private List<WriteBulkEntry> lastWriteBulkEntries = new ArrayList<>();
|
||||||
|
private List<Write2BulkEntry> lastWrite2BulkEntries = new ArrayList<>();
|
||||||
|
private List<WriteSecuredBulkEntry> lastWriteSecuredBulkEntries = new ArrayList<>();
|
||||||
|
private List<WriteSecured2BulkEntry> lastWriteSecured2BulkEntries = new ArrayList<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<BulkReadResult> readBulk(int serverHandle, List<String> items, int timeoutMs) {
|
||||||
|
lastReadBulkTimeoutMs = timeoutMs;
|
||||||
|
lastReadBulkItems = items;
|
||||||
|
List<BulkReadResult> results = new ArrayList<>();
|
||||||
|
for (int index = 0; index < items.size(); index++) {
|
||||||
|
results.add(BulkReadResult.newBuilder()
|
||||||
|
.setServerHandle(serverHandle)
|
||||||
|
.setTagAddress(items.get(index))
|
||||||
|
.setItemHandle(200 + index)
|
||||||
|
.setWasSuccessful(true)
|
||||||
|
.setWasCached(index % 2 == 0)
|
||||||
|
.setQuality(192)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<BulkWriteResult> writeBulk(int serverHandle, List<WriteBulkEntry> entries) {
|
||||||
|
lastWriteBulkEntries = entries;
|
||||||
|
List<BulkWriteResult> results = new ArrayList<>();
|
||||||
|
for (WriteBulkEntry entry : entries) {
|
||||||
|
results.add(BulkWriteResult.newBuilder()
|
||||||
|
.setServerHandle(serverHandle)
|
||||||
|
.setItemHandle(entry.getItemHandle())
|
||||||
|
.setWasSuccessful(true)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<BulkWriteResult> write2Bulk(int serverHandle, List<Write2BulkEntry> entries) {
|
||||||
|
lastWrite2BulkEntries = entries;
|
||||||
|
List<BulkWriteResult> results = new ArrayList<>();
|
||||||
|
for (Write2BulkEntry entry : entries) {
|
||||||
|
results.add(BulkWriteResult.newBuilder()
|
||||||
|
.setServerHandle(serverHandle)
|
||||||
|
.setItemHandle(entry.getItemHandle())
|
||||||
|
.setWasSuccessful(true)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<BulkWriteResult> writeSecuredBulk(int serverHandle, List<WriteSecuredBulkEntry> entries) {
|
||||||
|
lastWriteSecuredBulkEntries = entries;
|
||||||
|
List<BulkWriteResult> results = new ArrayList<>();
|
||||||
|
for (WriteSecuredBulkEntry entry : entries) {
|
||||||
|
results.add(BulkWriteResult.newBuilder()
|
||||||
|
.setServerHandle(serverHandle)
|
||||||
|
.setItemHandle(entry.getItemHandle())
|
||||||
|
.setWasSuccessful(true)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<BulkWriteResult> writeSecured2Bulk(int serverHandle, List<WriteSecured2BulkEntry> entries) {
|
||||||
|
lastWriteSecured2BulkEntries = entries;
|
||||||
|
List<BulkWriteResult> results = new ArrayList<>();
|
||||||
|
for (WriteSecured2BulkEntry entry : entries) {
|
||||||
|
results.add(BulkWriteResult.newBuilder()
|
||||||
|
.setServerHandle(serverHandle)
|
||||||
|
.setItemHandle(entry.getItemHandle())
|
||||||
|
.setWasSuccessful(true)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public com.dohertylan.mxgateway.client.MxEventStream streamEventsAfter(long afterWorkerSequence) {
|
||||||
|
throw new UnsupportedOperationException("stream-events is covered by client tests");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProtocolStatus ok() {
|
||||||
|
return ProtocolStatus.newBuilder()
|
||||||
|
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
plugins {
|
||||||
|
id 'java-library'
|
||||||
|
id 'com.google.protobuf'
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
api "com.google.protobuf:protobuf-java-util:${protobufVersion}"
|
||||||
|
api "com.google.protobuf:protobuf-java:${protobufVersion}"
|
||||||
|
api "io.grpc:grpc-protobuf:${grpcVersion}"
|
||||||
|
api "io.grpc:grpc-stub:${grpcVersion}"
|
||||||
|
|
||||||
|
implementation "com.google.guava:guava:${guavaVersion}"
|
||||||
|
implementation "io.grpc:grpc-netty-shaded:${grpcVersion}"
|
||||||
|
|
||||||
|
compileOnly 'javax.annotation:javax.annotation-api:1.3.2'
|
||||||
|
|
||||||
|
testImplementation "com.google.code.gson:gson:${gsonVersion}"
|
||||||
|
testImplementation "io.grpc:grpc-inprocess:${grpcVersion}"
|
||||||
|
testImplementation "io.grpc:grpc-testing:${grpcVersion}"
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
main {
|
||||||
|
proto {
|
||||||
|
srcDir rootProject.file('../../src/MxGateway.Contracts/Protos')
|
||||||
|
include 'mxaccess_gateway.proto'
|
||||||
|
include 'mxaccess_worker.proto'
|
||||||
|
include 'galaxy_repository.proto'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protobuf {
|
||||||
|
protoc {
|
||||||
|
artifact = "com.google.protobuf:protoc:${protobufVersion}"
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
grpc {
|
||||||
|
artifact = "io.grpc:protoc-gen-grpc-java:${grpcVersion}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
generatedFilesBaseDir = rootProject.file('src/main/generated').absolutePath
|
||||||
|
|
||||||
|
generateProtoTasks {
|
||||||
|
all().configureEach {
|
||||||
|
plugins {
|
||||||
|
grpc {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
package com.dohertylan.mxgateway.client;
|
||||||
|
|
||||||
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
|
||||||
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest;
|
||||||
|
import io.grpc.Status;
|
||||||
|
import io.grpc.StatusRuntimeException;
|
||||||
|
import io.grpc.stub.ClientCallStreamObserver;
|
||||||
|
import io.grpc.stub.ClientResponseObserver;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.NoSuchElementException;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.concurrent.ArrayBlockingQueue;
|
||||||
|
import java.util.concurrent.BlockingQueue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterator-style adaptor over the {@code WatchDeployEvents} server-streaming
|
||||||
|
* RPC. Mirrors {@link MxEventStream}: events arrive on a background gRPC thread
|
||||||
|
* and are buffered in a bounded blocking queue; the iterator drains them.
|
||||||
|
* Closing the stream cancels the underlying gRPC call.
|
||||||
|
*
|
||||||
|
* <p><strong>Threading:</strong> the iterator methods ({@link #hasNext()} and
|
||||||
|
* {@link #next()}) are <em>not</em> thread-safe and must be driven by a single
|
||||||
|
* consumer thread. {@link #close()} may be called from any thread. Terminal
|
||||||
|
* state transitions (queue overflow, server completion, and {@code close()})
|
||||||
|
* are serialised so that the first terminal condition wins deterministically:
|
||||||
|
* once an overflow exception has been observed it is never silently replaced
|
||||||
|
* by an end-of-stream marker.
|
||||||
|
*/
|
||||||
|
public final class DeployEventStream implements Iterator<DeployEvent>, AutoCloseable {
|
||||||
|
private static final Object END = new Object();
|
||||||
|
|
||||||
|
private final BlockingQueue<Object> queue;
|
||||||
|
private final Object terminalLock = new Object();
|
||||||
|
private volatile ClientCallStreamObserver<WatchDeployEventsRequest> requestStream;
|
||||||
|
private volatile boolean closed;
|
||||||
|
private boolean terminated;
|
||||||
|
private Object next;
|
||||||
|
|
||||||
|
DeployEventStream(int capacity) {
|
||||||
|
queue = new ArrayBlockingQueue<>(capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
ClientResponseObserver<WatchDeployEventsRequest, DeployEvent> observer() {
|
||||||
|
return new ClientResponseObserver<>() {
|
||||||
|
@Override
|
||||||
|
public void beforeStart(ClientCallStreamObserver<WatchDeployEventsRequest> requestStream) {
|
||||||
|
DeployEventStream.this.requestStream = requestStream;
|
||||||
|
if (closed) {
|
||||||
|
requestStream.cancel("client cancelled deploy event stream", null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onNext(DeployEvent value) {
|
||||||
|
offer(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(Throwable error) {
|
||||||
|
if (Status.fromThrowable(error).getCode() == Status.Code.CANCELLED && closed) {
|
||||||
|
offer(END);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
offer(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCompleted() {
|
||||||
|
offer(END);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean hasNext() {
|
||||||
|
if (next == END) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (next == null) {
|
||||||
|
next = take();
|
||||||
|
}
|
||||||
|
if (next instanceof RuntimeException runtimeException) {
|
||||||
|
next = END;
|
||||||
|
throw runtimeException;
|
||||||
|
}
|
||||||
|
if (next instanceof Throwable throwable) {
|
||||||
|
next = END;
|
||||||
|
throw new MxGatewayException(
|
||||||
|
"galaxy watch deploy events failed: " + throwable.getMessage(), throwable);
|
||||||
|
}
|
||||||
|
return next != END;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public DeployEvent next() {
|
||||||
|
if (!hasNext()) {
|
||||||
|
throw new NoSuchElementException();
|
||||||
|
}
|
||||||
|
Object value = next;
|
||||||
|
next = null;
|
||||||
|
return (DeployEvent) value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
closed = true;
|
||||||
|
ClientCallStreamObserver<WatchDeployEventsRequest> stream = requestStream;
|
||||||
|
if (stream != null) {
|
||||||
|
stream.cancel("client cancelled deploy event stream", null);
|
||||||
|
}
|
||||||
|
terminate(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object take() {
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return queue.take();
|
||||||
|
} catch (InterruptedException error) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
return new StatusRuntimeException(
|
||||||
|
Status.CANCELLED.withDescription("interrupted while reading deploy events"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void offer(Object value) {
|
||||||
|
Objects.requireNonNull(value, "value");
|
||||||
|
if (value == END) {
|
||||||
|
terminate(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!queue.offer(value)) {
|
||||||
|
ClientCallStreamObserver<WatchDeployEventsRequest> stream = requestStream;
|
||||||
|
if (stream != null) {
|
||||||
|
stream.cancel("client deploy event stream queue overflowed", null);
|
||||||
|
}
|
||||||
|
terminate(new MxGatewayException("galaxy watch deploy events queue overflowed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the single terminal transition. The first caller wins: a later
|
||||||
|
* end-of-stream or {@code close()} cannot overwrite or discard an overflow
|
||||||
|
* exception that has already been published to the consumer. Mirrors the
|
||||||
|
* {@link MxEventStream#terminate} contract — see Client.Java-002 for the
|
||||||
|
* race this guards against.
|
||||||
|
*
|
||||||
|
* @param fault the fault to surface to the consumer, or {@code null} for a
|
||||||
|
* clean end-of-stream
|
||||||
|
*/
|
||||||
|
private void terminate(MxGatewayException fault) {
|
||||||
|
synchronized (terminalLock) {
|
||||||
|
if (terminated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
terminated = true;
|
||||||
|
if (fault != null) {
|
||||||
|
// Make room for the fault marker; the consumer only needs the
|
||||||
|
// terminal signal, queued data events are no longer relevant.
|
||||||
|
queue.clear();
|
||||||
|
queue.offer(fault);
|
||||||
|
queue.offer(END);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Clean end-of-stream: ensure the END marker is delivered even when
|
||||||
|
// the queue is currently full of undrained data events.
|
||||||
|
if (!queue.offer(END)) {
|
||||||
|
queue.clear();
|
||||||
|
queue.offer(END);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
package com.dohertylan.mxgateway.client;
|
||||||
|
|
||||||
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
|
||||||
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest;
|
||||||
|
import io.grpc.stub.ClientCallStreamObserver;
|
||||||
|
import io.grpc.stub.ClientResponseObserver;
|
||||||
|
import io.grpc.stub.StreamObserver;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancellable handle returned by the async {@code watchDeployEvents} variant.
|
||||||
|
* Mirrors {@link MxGatewayEventSubscription} but for the Galaxy Repository
|
||||||
|
* deploy-event stream.
|
||||||
|
*/
|
||||||
|
public final class DeployEventSubscription implements AutoCloseable {
|
||||||
|
private final AtomicReference<ClientCallStreamObserver<WatchDeployEventsRequest>> requestStream =
|
||||||
|
new AtomicReference<>();
|
||||||
|
private final AtomicBoolean cancelled = new AtomicBoolean();
|
||||||
|
|
||||||
|
ClientResponseObserver<WatchDeployEventsRequest, DeployEvent> wrap(StreamObserver<DeployEvent> observer) {
|
||||||
|
return new ClientResponseObserver<>() {
|
||||||
|
@Override
|
||||||
|
public void beforeStart(ClientCallStreamObserver<WatchDeployEventsRequest> stream) {
|
||||||
|
requestStream.set(stream);
|
||||||
|
if (cancelled.get()) {
|
||||||
|
stream.cancel("client cancelled deploy event stream", null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onNext(DeployEvent value) {
|
||||||
|
observer.onNext(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(Throwable error) {
|
||||||
|
observer.onError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCompleted() {
|
||||||
|
observer.onCompleted();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels the underlying gRPC call. Safe to invoke before the call has
|
||||||
|
* started; cancellation is recorded and applied as soon as the stream
|
||||||
|
* attaches.
|
||||||
|
*/
|
||||||
|
public void cancel() {
|
||||||
|
cancelled.set(true);
|
||||||
|
ClientCallStreamObserver<WatchDeployEventsRequest> stream = requestStream.get();
|
||||||
|
if (stream != null) {
|
||||||
|
stream.cancel("client cancelled deploy event stream", null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
+371
@@ -0,0 +1,371 @@
|
|||||||
|
package com.dohertylan.mxgateway.client;
|
||||||
|
|
||||||
|
import galaxy_repository.v1.GalaxyRepositoryGrpc;
|
||||||
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
|
||||||
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply;
|
||||||
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest;
|
||||||
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject;
|
||||||
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply;
|
||||||
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest;
|
||||||
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply;
|
||||||
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest;
|
||||||
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest;
|
||||||
|
import com.google.protobuf.Timestamp;
|
||||||
|
import io.grpc.Channel;
|
||||||
|
import io.grpc.ClientInterceptors;
|
||||||
|
import io.grpc.ManagedChannel;
|
||||||
|
import io.grpc.stub.StreamObserver;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin wrapper around the generated {@link GalaxyRepositoryGrpc} stubs that
|
||||||
|
* exposes the three metadata-only RPCs of the Galaxy Repository service in
|
||||||
|
* idiomatic Java types. Mirrors the constructor and option-handling style of
|
||||||
|
* {@link MxGatewayClient}.
|
||||||
|
*/
|
||||||
|
public final class GalaxyRepositoryClient implements AutoCloseable {
|
||||||
|
private static final int DISCOVER_HIERARCHY_PAGE_SIZE = 5000;
|
||||||
|
|
||||||
|
private final ManagedChannel ownedChannel;
|
||||||
|
private final MxGatewayClientOptions options;
|
||||||
|
private final GalaxyRepositoryGrpc.GalaxyRepositoryBlockingStub blockingStub;
|
||||||
|
private final GalaxyRepositoryGrpc.GalaxyRepositoryFutureStub futureStub;
|
||||||
|
private final GalaxyRepositoryGrpc.GalaxyRepositoryStub asyncStub;
|
||||||
|
|
||||||
|
private GalaxyRepositoryClient(ManagedChannel channel, MxGatewayClientOptions options) {
|
||||||
|
this.ownedChannel = channel;
|
||||||
|
this.options = options;
|
||||||
|
Channel intercepted = ClientInterceptors.intercept(channel, new MxGatewayAuthInterceptor(options.apiKey()));
|
||||||
|
blockingStub = GalaxyRepositoryGrpc.newBlockingStub(intercepted);
|
||||||
|
futureStub = GalaxyRepositoryGrpc.newFutureStub(intercepted);
|
||||||
|
asyncStub = GalaxyRepositoryGrpc.newStub(intercepted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a client over a caller-managed {@link Channel}. The caller owns
|
||||||
|
* channel lifecycle; {@link #close()} is a no-op for this constructor.
|
||||||
|
*
|
||||||
|
* @param channel the gRPC channel to use for outbound calls
|
||||||
|
* @param options the client options carrying the API key and timeouts
|
||||||
|
* @throws NullPointerException if {@code options} is {@code null}
|
||||||
|
*/
|
||||||
|
public GalaxyRepositoryClient(Channel channel, MxGatewayClientOptions options) {
|
||||||
|
this.ownedChannel = null;
|
||||||
|
this.options = Objects.requireNonNull(options, "options");
|
||||||
|
Channel intercepted = ClientInterceptors.intercept(channel, new MxGatewayAuthInterceptor(options.apiKey()));
|
||||||
|
blockingStub = GalaxyRepositoryGrpc.newBlockingStub(intercepted);
|
||||||
|
futureStub = GalaxyRepositoryGrpc.newFutureStub(intercepted);
|
||||||
|
asyncStub = GalaxyRepositoryGrpc.newStub(intercepted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a new client and owns its channel; {@link #close()} shuts the
|
||||||
|
* channel down.
|
||||||
|
*
|
||||||
|
* @param options the client options carrying the endpoint and credentials
|
||||||
|
* @return a connected client
|
||||||
|
*/
|
||||||
|
public static GalaxyRepositoryClient connect(MxGatewayClientOptions options) {
|
||||||
|
return new GalaxyRepositoryClient(
|
||||||
|
MxGatewayChannels.createChannel(options, "failed to configure galaxy repository TLS"), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the underlying blocking stub with the per-call deadline applied.
|
||||||
|
*
|
||||||
|
* @return the blocking stub
|
||||||
|
*/
|
||||||
|
public GalaxyRepositoryGrpc.GalaxyRepositoryBlockingStub rawBlockingStub() {
|
||||||
|
return MxGatewayChannels.withDeadline(blockingStub, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the underlying future stub with the per-call deadline applied.
|
||||||
|
*
|
||||||
|
* @return the future stub
|
||||||
|
*/
|
||||||
|
public GalaxyRepositoryGrpc.GalaxyRepositoryFutureStub rawFutureStub() {
|
||||||
|
return MxGatewayChannels.withDeadline(futureStub, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the underlying async stub. Stream deadlines are applied per call.
|
||||||
|
*
|
||||||
|
* @return the async stub
|
||||||
|
*/
|
||||||
|
public GalaxyRepositoryGrpc.GalaxyRepositoryStub rawAsyncStub() {
|
||||||
|
return asyncStub;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invokes the {@code TestConnection} RPC and returns the {@code ok} flag.
|
||||||
|
*
|
||||||
|
* @return {@code true} when the gateway reached the Galaxy Repository database
|
||||||
|
* @throws MxGatewayException on transport or protocol failure
|
||||||
|
*/
|
||||||
|
public boolean testConnection() {
|
||||||
|
try {
|
||||||
|
TestConnectionReply reply = rawBlockingStub().testConnection(TestConnectionRequest.getDefaultInstance());
|
||||||
|
return reply.getOk();
|
||||||
|
} catch (RuntimeException error) {
|
||||||
|
if (error instanceof MxGatewayException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw MxGatewayErrors.fromGrpc("galaxy test connection", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invokes {@code TestConnection} asynchronously.
|
||||||
|
*
|
||||||
|
* @return a future completed with the {@code ok} flag, or completed
|
||||||
|
* exceptionally with {@link MxGatewayException} on failure
|
||||||
|
*/
|
||||||
|
public CompletableFuture<Boolean> testConnectionAsync() {
|
||||||
|
// Apply the projection inside toCompletable rather than via .thenApply
|
||||||
|
// so the user-visible future is the same future cancellation is bound
|
||||||
|
// to; a downstream .thenApply stage would not forward cancel() to the
|
||||||
|
// source RPC.
|
||||||
|
return MxGatewayChannels.toCompletable(
|
||||||
|
rawFutureStub().testConnection(TestConnectionRequest.getDefaultInstance()),
|
||||||
|
"galaxy test connection",
|
||||||
|
TestConnectionReply::getOk);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invokes the {@code GetLastDeployTime} RPC.
|
||||||
|
*
|
||||||
|
* @return the time of the last deploy, or {@link Optional#empty()} when the
|
||||||
|
* server reports {@code present=false}
|
||||||
|
* @throws MxGatewayException on transport or protocol failure
|
||||||
|
*/
|
||||||
|
public Optional<Instant> getLastDeployTime() {
|
||||||
|
try {
|
||||||
|
GetLastDeployTimeReply reply =
|
||||||
|
rawBlockingStub().getLastDeployTime(GetLastDeployTimeRequest.getDefaultInstance());
|
||||||
|
return mapDeployTime(reply);
|
||||||
|
} catch (RuntimeException error) {
|
||||||
|
if (error instanceof MxGatewayException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw MxGatewayErrors.fromGrpc("galaxy get last deploy time", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invokes {@code GetLastDeployTime} asynchronously.
|
||||||
|
*
|
||||||
|
* @return a future completed with the time of the last deploy, or
|
||||||
|
* {@link Optional#empty()} when the server reports {@code present=false};
|
||||||
|
* completed exceptionally with {@link MxGatewayException} on failure
|
||||||
|
*/
|
||||||
|
public CompletableFuture<Optional<Instant>> getLastDeployTimeAsync() {
|
||||||
|
return MxGatewayChannels.toCompletable(
|
||||||
|
rawFutureStub().getLastDeployTime(GetLastDeployTimeRequest.getDefaultInstance()),
|
||||||
|
"galaxy get last deploy time",
|
||||||
|
GalaxyRepositoryClient::mapDeployTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invokes the {@code DiscoverHierarchy} RPC and returns the generated
|
||||||
|
* {@link GalaxyObject} messages directly. Callers can read every field of
|
||||||
|
* the proto message without an extra DTO layer.
|
||||||
|
*
|
||||||
|
* @return the Galaxy object hierarchy
|
||||||
|
* @throws MxGatewayException on transport or protocol failure
|
||||||
|
*/
|
||||||
|
public List<GalaxyObject> discoverHierarchy() {
|
||||||
|
try {
|
||||||
|
java.util.ArrayList<GalaxyObject> objects = new java.util.ArrayList<>();
|
||||||
|
java.util.HashSet<String> seenPageTokens = new java.util.HashSet<>();
|
||||||
|
String pageToken = "";
|
||||||
|
do {
|
||||||
|
DiscoverHierarchyReply reply = rawBlockingStub().discoverHierarchy(DiscoverHierarchyRequest.newBuilder()
|
||||||
|
.setPageSize(DISCOVER_HIERARCHY_PAGE_SIZE)
|
||||||
|
.setPageToken(pageToken)
|
||||||
|
.build());
|
||||||
|
objects.addAll(reply.getObjectsList());
|
||||||
|
pageToken = reply.getNextPageToken();
|
||||||
|
if (!pageToken.isBlank() && !seenPageTokens.add(pageToken)) {
|
||||||
|
throw new MxGatewayException(
|
||||||
|
"galaxy discover hierarchy returned repeated page token: " + pageToken);
|
||||||
|
}
|
||||||
|
} while (!pageToken.isBlank());
|
||||||
|
return objects;
|
||||||
|
} catch (RuntimeException error) {
|
||||||
|
if (error instanceof MxGatewayException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw MxGatewayErrors.fromGrpc("galaxy discover hierarchy", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invokes {@code DiscoverHierarchy} asynchronously.
|
||||||
|
*
|
||||||
|
* @return a future completed with the Galaxy object hierarchy, or completed
|
||||||
|
* exceptionally with {@link MxGatewayException} on failure
|
||||||
|
*/
|
||||||
|
public CompletableFuture<List<GalaxyObject>> discoverHierarchyAsync() {
|
||||||
|
// The recursive page chain produces a fresh in-flight RPC per page.
|
||||||
|
// Track the current in-flight stage in an AtomicReference and return a
|
||||||
|
// user-facing future whose cancel() forwards to that current stage —
|
||||||
|
// otherwise cancelling the chained CompletableFuture would not abort
|
||||||
|
// the in-flight gRPC call. Without this, .thenCompose creates new
|
||||||
|
// stages whose cancel() does not propagate upstream.
|
||||||
|
AtomicReference<CompletableFuture<?>> currentStage = new AtomicReference<>();
|
||||||
|
CompletableFuture<List<GalaxyObject>> userFuture = new CompletableFuture<>() {
|
||||||
|
@Override
|
||||||
|
public boolean cancel(boolean mayInterruptIfRunning) {
|
||||||
|
boolean cancelled = super.cancel(mayInterruptIfRunning);
|
||||||
|
CompletableFuture<?> stage = currentStage.get();
|
||||||
|
if (stage != null) {
|
||||||
|
stage.cancel(mayInterruptIfRunning);
|
||||||
|
}
|
||||||
|
return cancelled;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
discoverHierarchyPageAsync("", new java.util.ArrayList<>(), new java.util.HashSet<>(), currentStage)
|
||||||
|
.whenComplete((result, error) -> {
|
||||||
|
if (error != null) {
|
||||||
|
userFuture.completeExceptionally(error);
|
||||||
|
} else {
|
||||||
|
userFuture.complete(result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return userFuture;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to {@code WatchDeployEvents} via the async stub and consumes
|
||||||
|
* results through a blocking iterator. Closing the returned stream cancels
|
||||||
|
* the underlying gRPC call.
|
||||||
|
*
|
||||||
|
* @param lastSeenDeployTime optional. When non-{@code null}, the bootstrap
|
||||||
|
* event is suppressed if the cached deploy time matches.
|
||||||
|
* @return an iterator-style stream of deploy events
|
||||||
|
*/
|
||||||
|
public DeployEventStream watchDeployEvents(Instant lastSeenDeployTime) {
|
||||||
|
DeployEventStream stream = new DeployEventStream(16);
|
||||||
|
MxGatewayChannels.withStreamDeadline(rawAsyncStub(), options)
|
||||||
|
.watchDeployEvents(buildWatchRequest(lastSeenDeployTime), stream.observer());
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterator-style alias for {@link #watchDeployEvents(Instant)} matching the
|
||||||
|
* task-spec signature.
|
||||||
|
*
|
||||||
|
* @param lastSeenDeployTime optional cached deploy time for bootstrap suppression
|
||||||
|
* @return an iterator over deploy events
|
||||||
|
*/
|
||||||
|
public Iterator<DeployEvent> watchDeployEventsIterator(Instant lastSeenDeployTime) {
|
||||||
|
return watchDeployEvents(lastSeenDeployTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to {@code WatchDeployEvents} via the async stub, dispatching
|
||||||
|
* each event to {@code observer}. The returned subscription is cancellable
|
||||||
|
* and {@link AutoCloseable}.
|
||||||
|
*
|
||||||
|
* @param lastSeenDeployTime optional cached deploy time for bootstrap suppression
|
||||||
|
* @param observer caller-supplied observer that receives events and completion
|
||||||
|
* @return a cancellable subscription handle
|
||||||
|
* @throws NullPointerException if {@code observer} is {@code null}
|
||||||
|
*/
|
||||||
|
public DeployEventSubscription watchDeployEventsAsync(
|
||||||
|
Instant lastSeenDeployTime, StreamObserver<DeployEvent> observer) {
|
||||||
|
Objects.requireNonNull(observer, "observer");
|
||||||
|
DeployEventSubscription subscription = new DeployEventSubscription();
|
||||||
|
MxGatewayChannels.withStreamDeadline(rawAsyncStub(), options)
|
||||||
|
.watchDeployEvents(buildWatchRequest(lastSeenDeployTime), subscription.wrap(observer));
|
||||||
|
return subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WatchDeployEventsRequest buildWatchRequest(Instant lastSeenDeployTime) {
|
||||||
|
WatchDeployEventsRequest.Builder builder = WatchDeployEventsRequest.newBuilder();
|
||||||
|
if (lastSeenDeployTime != null) {
|
||||||
|
builder.setLastSeenDeployTime(Timestamp.newBuilder()
|
||||||
|
.setSeconds(lastSeenDeployTime.getEpochSecond())
|
||||||
|
.setNanos(lastSeenDeployTime.getNano())
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shuts the owned channel down and awaits termination so try-with-resources
|
||||||
|
* callers do not leave in-flight calls or Netty event-loop threads running
|
||||||
|
* after the block exits.
|
||||||
|
*
|
||||||
|
* <p>Waits up to {@link MxGatewayClientOptions#shutdownTimeout()} for
|
||||||
|
* graceful termination and forcibly shuts the channel down on timeout. If
|
||||||
|
* the calling thread is interrupted while waiting, the channel is forcibly
|
||||||
|
* shut down and the thread's interrupt flag is restored. No-op for clients
|
||||||
|
* that do not own their channel. For an explicitly checked, blocking-aware
|
||||||
|
* shutdown call {@link #closeAndAwaitTermination()}. Delegates to the
|
||||||
|
* shared {@link MxGatewayChannels#shutdown} so behavior stays in lockstep
|
||||||
|
* with {@link MxGatewayClient}.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
MxGatewayChannels.shutdown(ownedChannel, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shuts the owned channel down and waits up to
|
||||||
|
* {@link MxGatewayClientOptions#shutdownTimeout()} for termination,
|
||||||
|
* forcibly shutting it down on timeout. No-op for clients that do not own
|
||||||
|
* their channel.
|
||||||
|
*
|
||||||
|
* @throws InterruptedException if the calling thread is interrupted while waiting
|
||||||
|
*/
|
||||||
|
public void closeAndAwaitTermination() throws InterruptedException {
|
||||||
|
MxGatewayChannels.shutdownAndAwaitTermination(ownedChannel, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Optional<Instant> mapDeployTime(GetLastDeployTimeReply reply) {
|
||||||
|
if (!reply.getPresent()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
Timestamp ts = reply.getTimeOfLastDeploy();
|
||||||
|
return Optional.of(Instant.ofEpochSecond(ts.getSeconds(), ts.getNanos()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private CompletableFuture<List<GalaxyObject>> discoverHierarchyPageAsync(
|
||||||
|
String pageToken,
|
||||||
|
java.util.ArrayList<GalaxyObject> objects,
|
||||||
|
java.util.HashSet<String> seenPageTokens,
|
||||||
|
AtomicReference<CompletableFuture<?>> currentStage) {
|
||||||
|
DiscoverHierarchyRequest request = DiscoverHierarchyRequest.newBuilder()
|
||||||
|
.setPageSize(DISCOVER_HIERARCHY_PAGE_SIZE)
|
||||||
|
.setPageToken(pageToken)
|
||||||
|
.build();
|
||||||
|
CompletableFuture<DiscoverHierarchyReply> pageFuture = MxGatewayChannels.toCompletable(
|
||||||
|
rawFutureStub().discoverHierarchy(request), "galaxy discover hierarchy");
|
||||||
|
// Publish the in-flight page future so a user cancellation can abort
|
||||||
|
// the current outstanding RPC (the recursion replaces this reference
|
||||||
|
// before each subsequent page).
|
||||||
|
currentStage.set(pageFuture);
|
||||||
|
return pageFuture.thenCompose(reply -> {
|
||||||
|
objects.addAll(reply.getObjectsList());
|
||||||
|
if (reply.getNextPageToken().isBlank()) {
|
||||||
|
return CompletableFuture.completedFuture(objects);
|
||||||
|
}
|
||||||
|
if (!seenPageTokens.add(reply.getNextPageToken())) {
|
||||||
|
CompletableFuture<List<GalaxyObject>> failed = new CompletableFuture<>();
|
||||||
|
failed.completeExceptionally(new MxGatewayException(
|
||||||
|
"galaxy discover hierarchy returned repeated page token: "
|
||||||
|
+ reply.getNextPageToken()));
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
|
return discoverHierarchyPageAsync(reply.getNextPageToken(), objects, seenPageTokens, currentStage);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package com.dohertylan.mxgateway.client;
|
||||||
|
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when the worker reports an MXAccess COM-side failure. Distinguishes
|
||||||
|
* MXAccess errors (non-zero {@code HResult} or unsuccessful {@code MxStatusProxy})
|
||||||
|
* from other gateway protocol failures.
|
||||||
|
*/
|
||||||
|
public final class MxAccessException extends MxGatewayCommandException {
|
||||||
|
/**
|
||||||
|
* Creates a new MXAccess exception with an explicit protocol status.
|
||||||
|
*
|
||||||
|
* @param operation human-readable name of the failing operation
|
||||||
|
* @param protocolStatus protocol status reported by the gateway
|
||||||
|
* @param reply raw command reply containing the MXAccess failure detail
|
||||||
|
*/
|
||||||
|
public MxAccessException(String operation, ProtocolStatus protocolStatus, MxCommandReply reply) {
|
||||||
|
super(operation, protocolStatus, reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new MXAccess exception derived from a command reply.
|
||||||
|
*
|
||||||
|
* @param operation human-readable name of the failing operation
|
||||||
|
* @param reply raw command reply; the protocol status is taken from this reply when present
|
||||||
|
*/
|
||||||
|
public MxAccessException(String operation, MxCommandReply reply) {
|
||||||
|
super(operation, reply == null ? null : reply.getProtocolStatus(), reply);
|
||||||
|
}
|
||||||
|
}
|
||||||
+194
@@ -0,0 +1,194 @@
|
|||||||
|
package com.dohertylan.mxgateway.client;
|
||||||
|
|
||||||
|
import io.grpc.Status;
|
||||||
|
import io.grpc.StatusRuntimeException;
|
||||||
|
import io.grpc.stub.ClientCallStreamObserver;
|
||||||
|
import io.grpc.stub.ClientResponseObserver;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.NoSuchElementException;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.concurrent.ArrayBlockingQueue;
|
||||||
|
import java.util.concurrent.BlockingQueue;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterator-style adaptor over the gateway {@code StreamEvents} server-streaming
|
||||||
|
* RPC.
|
||||||
|
*
|
||||||
|
* <p>Events arrive on a background gRPC thread and are buffered in a bounded
|
||||||
|
* blocking queue; the iterator drains them on the calling thread. Closing the
|
||||||
|
* stream cancels the underlying gRPC call. If the queue overflows the call is
|
||||||
|
* cancelled and a follow-up call to {@link #next()} throws
|
||||||
|
* {@link MxGatewayException}.
|
||||||
|
*
|
||||||
|
* <p><strong>Backpressure (fail-fast):</strong> this adaptor relies on gRPC's
|
||||||
|
* default auto-inbound flow control — the async stub auto-requests messages, so
|
||||||
|
* the gateway can push events faster than the consumer drains the bounded
|
||||||
|
* 1024-element buffer (the buffer capacity is a constructor parameter; the
|
||||||
|
* production caller {@code MxGatewayClient.streamEvents} passes {@code 1024} to
|
||||||
|
* absorb the gateway's session-backlog replay burst). There is intentionally
|
||||||
|
* <em>no</em> real client flow control: a consumer that stalls long enough to
|
||||||
|
* let the buffer fill triggers an immediate overflow that cancels the
|
||||||
|
* subscription and surfaces an {@link MxGatewayException} on the next
|
||||||
|
* {@link #next()} call. This matches the gateway's documented fail-fast
|
||||||
|
* event-backpressure design — a slow consumer loses its subscription rather
|
||||||
|
* than silently dropping events. Consumers that cannot keep up must drain
|
||||||
|
* {@link #next()} promptly (e.g. hand events to their own larger queue) and be
|
||||||
|
* prepared to resubscribe with a resume cursor.
|
||||||
|
*
|
||||||
|
* <p><strong>Threading:</strong> the iterator methods ({@link #hasNext()} and
|
||||||
|
* {@link #next()}) are <em>not</em> thread-safe and must be driven by a single
|
||||||
|
* consumer thread. {@link #close()} may be called from any thread. Terminal
|
||||||
|
* state transitions (queue overflow, server completion, and {@code close()})
|
||||||
|
* are serialised so that the first terminal condition wins deterministically:
|
||||||
|
* once an overflow exception has been observed it is never silently replaced
|
||||||
|
* by an end-of-stream marker.
|
||||||
|
*/
|
||||||
|
public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
|
||||||
|
private static final Object END = new Object();
|
||||||
|
|
||||||
|
private final BlockingQueue<Object> queue;
|
||||||
|
private final Object terminalLock = new Object();
|
||||||
|
private volatile ClientCallStreamObserver<StreamEventsRequest> requestStream;
|
||||||
|
private volatile boolean closed;
|
||||||
|
private boolean terminated;
|
||||||
|
private Object next;
|
||||||
|
|
||||||
|
MxEventStream(int capacity) {
|
||||||
|
queue = new ArrayBlockingQueue<>(capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
ClientResponseObserver<StreamEventsRequest, MxEvent> observer() {
|
||||||
|
return new ClientResponseObserver<>() {
|
||||||
|
@Override
|
||||||
|
public void beforeStart(ClientCallStreamObserver<StreamEventsRequest> requestStream) {
|
||||||
|
// Resolve the close()/beforeStart() race the same way DeployEventStream does:
|
||||||
|
// store the request stream first, then check the close flag and cancel the
|
||||||
|
// call if a prior close() already fired. Without this, a close() that ran
|
||||||
|
// before the gRPC call attached its ClientCallStreamObserver would skip
|
||||||
|
// stream.cancel() (because requestStream is still null) and beforeStart()
|
||||||
|
// arriving afterwards would leak the underlying call open.
|
||||||
|
MxEventStream.this.requestStream = requestStream;
|
||||||
|
if (closed) {
|
||||||
|
requestStream.cancel("client cancelled event stream", null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onNext(MxEvent value) {
|
||||||
|
offer(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(Throwable error) {
|
||||||
|
if (Status.fromThrowable(error).getCode() == Status.Code.CANCELLED && closed) {
|
||||||
|
offer(END);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
offer(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCompleted() {
|
||||||
|
offer(END);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean hasNext() {
|
||||||
|
if (next == END) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (next == null) {
|
||||||
|
next = take();
|
||||||
|
}
|
||||||
|
if (next instanceof RuntimeException runtimeException) {
|
||||||
|
next = END;
|
||||||
|
throw runtimeException;
|
||||||
|
}
|
||||||
|
if (next instanceof Throwable throwable) {
|
||||||
|
next = END;
|
||||||
|
throw new MxGatewayException("gateway stream events failed: " + throwable.getMessage(), throwable);
|
||||||
|
}
|
||||||
|
return next != END;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MxEvent next() {
|
||||||
|
if (!hasNext()) {
|
||||||
|
throw new NoSuchElementException();
|
||||||
|
}
|
||||||
|
Object value = next;
|
||||||
|
next = null;
|
||||||
|
return (MxEvent) value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
closed = true;
|
||||||
|
ClientCallStreamObserver<StreamEventsRequest> stream = requestStream;
|
||||||
|
if (stream != null) {
|
||||||
|
stream.cancel("client cancelled event stream", null);
|
||||||
|
}
|
||||||
|
terminate(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object take() {
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return queue.take();
|
||||||
|
} catch (InterruptedException error) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
return new StatusRuntimeException(Status.CANCELLED.withDescription("interrupted while reading events"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void offer(Object value) {
|
||||||
|
Objects.requireNonNull(value, "value");
|
||||||
|
if (value == END) {
|
||||||
|
terminate(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!queue.offer(value)) {
|
||||||
|
ClientCallStreamObserver<StreamEventsRequest> stream = requestStream;
|
||||||
|
if (stream != null) {
|
||||||
|
stream.cancel("client event stream queue overflowed", null);
|
||||||
|
}
|
||||||
|
terminate(new MxGatewayException("gateway stream events queue overflowed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the single terminal transition. The first caller wins: a later
|
||||||
|
* end-of-stream or {@code close()} cannot overwrite or discard an overflow
|
||||||
|
* exception that has already been published to the consumer.
|
||||||
|
*
|
||||||
|
* @param fault the fault to surface to the consumer, or {@code null} for a
|
||||||
|
* clean end-of-stream
|
||||||
|
*/
|
||||||
|
private void terminate(MxGatewayException fault) {
|
||||||
|
synchronized (terminalLock) {
|
||||||
|
if (terminated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
terminated = true;
|
||||||
|
if (fault != null) {
|
||||||
|
// Make room for the fault marker; the consumer only needs the
|
||||||
|
// terminal signal, queued data events are no longer relevant.
|
||||||
|
queue.clear();
|
||||||
|
queue.offer(fault);
|
||||||
|
queue.offer(END);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Clean end-of-stream: ensure the END marker is delivered even when
|
||||||
|
// the queue is currently full of undrained data events.
|
||||||
|
if (!queue.offer(END)) {
|
||||||
|
queue.clear();
|
||||||
|
queue.offer(END);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
package com.dohertylan.mxgateway.client;
|
||||||
|
|
||||||
|
import io.grpc.stub.ClientCallStreamObserver;
|
||||||
|
import io.grpc.stub.ClientResponseObserver;
|
||||||
|
import io.grpc.stub.StreamObserver;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancellable handle returned by {@code streamAlarms}.
|
||||||
|
*
|
||||||
|
* <p>Wraps a caller-supplied {@link StreamObserver} and exposes a
|
||||||
|
* {@link #cancel()} entry point that aborts the underlying gRPC call. The
|
||||||
|
* subscription also implements {@link AutoCloseable} so it can participate in
|
||||||
|
* try-with-resources blocks.
|
||||||
|
*/
|
||||||
|
public final class MxGatewayAlarmFeedSubscription implements AutoCloseable {
|
||||||
|
private final AtomicReference<ClientCallStreamObserver<StreamAlarmsRequest>> requestStream = new AtomicReference<>();
|
||||||
|
private final AtomicBoolean cancelled = new AtomicBoolean();
|
||||||
|
|
||||||
|
ClientResponseObserver<StreamAlarmsRequest, AlarmFeedMessage> wrap(StreamObserver<AlarmFeedMessage> observer) {
|
||||||
|
return new ClientResponseObserver<>() {
|
||||||
|
@Override
|
||||||
|
public void beforeStart(ClientCallStreamObserver<StreamAlarmsRequest> stream) {
|
||||||
|
requestStream.set(stream);
|
||||||
|
if (cancelled.get()) {
|
||||||
|
stream.cancel("client cancelled alarm feed", null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onNext(AlarmFeedMessage value) {
|
||||||
|
observer.onNext(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(Throwable error) {
|
||||||
|
observer.onError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCompleted() {
|
||||||
|
observer.onCompleted();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels the underlying gRPC call. Safe to invoke before the call has
|
||||||
|
* started; cancellation is recorded and applied as soon as the stream
|
||||||
|
* attaches.
|
||||||
|
*/
|
||||||
|
public void cancel() {
|
||||||
|
cancelled.set(true);
|
||||||
|
ClientCallStreamObserver<StreamAlarmsRequest> stream = requestStream.get();
|
||||||
|
if (stream != null) {
|
||||||
|
stream.cancel("client cancelled alarm feed", null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
package com.dohertylan.mxgateway.client;
|
||||||
|
|
||||||
|
import io.grpc.CallOptions;
|
||||||
|
import io.grpc.Channel;
|
||||||
|
import io.grpc.ClientCall;
|
||||||
|
import io.grpc.ClientInterceptor;
|
||||||
|
import io.grpc.ForwardingClientCall;
|
||||||
|
import io.grpc.Metadata;
|
||||||
|
import io.grpc.MethodDescriptor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gRPC client interceptor that attaches the {@code authorization: Bearer ...}
|
||||||
|
* header carrying the gateway API key. A blank or {@code null} key disables
|
||||||
|
* the interceptor so unauthenticated calls pass through unchanged.
|
||||||
|
*/
|
||||||
|
public final class MxGatewayAuthInterceptor implements ClientInterceptor {
|
||||||
|
static final Metadata.Key<String> AUTHORIZATION_HEADER =
|
||||||
|
Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);
|
||||||
|
|
||||||
|
private final String apiKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new interceptor using the supplied API key.
|
||||||
|
*
|
||||||
|
* @param apiKey gateway API key; {@code null} or blank disables the interceptor
|
||||||
|
*/
|
||||||
|
public MxGatewayAuthInterceptor(String apiKey) {
|
||||||
|
this.apiKey = apiKey == null ? "" : apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
|
||||||
|
MethodDescriptor<ReqT, RespT> method, CallOptions callOptions, Channel next) {
|
||||||
|
ClientCall<ReqT, RespT> call = next.newCall(method, callOptions);
|
||||||
|
if (apiKey.isBlank()) {
|
||||||
|
return call;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ForwardingClientCall.SimpleForwardingClientCall<>(call) {
|
||||||
|
@Override
|
||||||
|
public void start(Listener<RespT> responseListener, Metadata headers) {
|
||||||
|
headers.put(AUTHORIZATION_HEADER, "Bearer " + apiKey);
|
||||||
|
super.start(responseListener, headers);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user