Files
UA-.NETStandard/docs/WebApi.md

316 lines
13 KiB
Markdown

# OPC UA REST Binding (Part 6 §G.3 "OpenAPI Mapping")
The OPC UA REST binding exposes the OPC UA service set as ASP.NET
Core **Minimal-API endpoints**, implementing the **OpenAPI Mapping**
defined in [OPC UA Part 6 §G.3](https://reference.opcfoundation.org/specs/OPC-10000-6/g-3)
(v1.05.07).
The binding ships as part of
`OPCFoundation.NetStandard.Opc.Ua.Bindings.Https` (net8+ only;
mounted on the same Kestrel host as the binary and
`application/opcua+uajson` sub-profiles).
- **Server side**: ASP.NET Core Minimal-API endpoints (one `MapPost`
per spec service) — **NativeAOT-compatible**; no MVC reflection, no
`[UnconditionalSuppressMessage]` attributes.
- **Client side**: symmetric `IWebApiClient` in
`OPCFoundation.NetStandard.Opc.Ua.Client` under
`src/Opc.Ua.Client/WebApi/`.
## Quick start
```csharp
using Microsoft.Extensions.DependencyInjection;
using Opc.Ua;
using Opc.Ua.Bindings;
services
.AddOpcUa()
.AddHttpsTransport() // existing binary + opcua+uajson
.AddWebApiTransport() // adds REST routes alongside them
.AddWebApiAnonymousAuth(); // or AddWebApiBearerAuth(...) etc.
```
The REST surface mounts inside the existing `HttpsTransportListener`
Kestrel pipeline, so a single port serves binary,
`application/opcua+uajson`, **and** REST traffic without configuring a
second listener.
A spec-shaped client request:
```bash
curl -X POST https://server:4843/read \
-H 'Content-Type: application/json' \
--data '{
"RequestHeader": { "TimeoutHint": 10000 },
"TimestampsToReturn": 2,
"NodesToRead": [
{ "NodeId": "i=2258", "AttributeId": 13 }
]
}'
```
returns the matching `ReadResponse` body. Add
`?encoding=verbose` to the `Content-Type` (or `Accept`) to opt into the
Verbose JSON flavour.
## Routes (full coverage)
The binding implements every service in the spec's
[`opc.ua.openapi.allservices.json`](https://github.com/OPCFoundation/UA-Nodeset/blob/latest/Schema/opc.ua.openapi.allservices.json)
document — 28 services across the Discovery, Session, View, Attribute,
Method, MonitoredItem, and Subscription service sets. NodeManagement
(Part 4 §5.8) and Query (§5.10) are **not in scope** — the spec
document deliberately omits them.
| Service set (Part 4) | Routes |
| --- | --- |
| Discovery (§5.5) | `/findservers`, `/getendpoints` |
| Session (§5.7) | `/createsession`, `/activatesession`, `/closesession`, `/cancel` |
| View (§5.9) | `/browse`, `/browsenext`, `/translate`, `/registernodes`, `/unregisternodes` |
| Attribute (§5.11) | `/read`, `/write`, `/historyread`, `/historyupdate` |
| Method (§5.12) | `/call` |
| MonitoredItem (§5.13) | `/createmonitoreditems`, `/modifymonitoreditems`, `/setmonitoringmode`, `/settriggering`, `/deletemonitoreditems` |
| Subscription (§5.14) | `/createsubscription`, `/modifysubscription`, `/setpublishingmode`, `/publish`, `/republish`, `/transfersubscriptions`, `/deletesubscriptions` |
All routes are `POST` with a JSON body holding the matching
`<Service>Request`; the response body is the matching
`<Service>Response`. The route table is the source of truth — see
`WebApiServiceRoutes` in `src/Opc.Ua.Core/Stack/WebApi/`.
## Encoding negotiation
OPC UA Part 6 §5.4 defines two JSON flavours: **Compact** (default,
mandatory) and **Verbose**. The REST binding selects between them via
the `encoding` media-type parameter:
| Request `Content-Type` | Server response `Content-Type` |
| --- | --- |
| `application/json` | `application/json; encoding=compact` |
| `application/json; encoding=compact` | `application/json; encoding=compact` |
| `application/json; encoding=verbose` | `application/json; encoding=verbose` |
| `application/json; encoding=verbose` + `Accept: application/json; encoding=compact` | `application/json; encoding=compact` |
The `Accept` header takes precedence for the response encoding; the
`Content-Type` header governs how the inbound body is decoded. Unknown
`encoding` parameter values fall back to **Compact**.
## Discovery
When the binding is registered, the `HttpsServiceHost` emits a
discovery-only twin per `SecurityMode.None` HTTPS-binary endpoint with:
- `TransportProfileUri = http://opcfoundation.org/UA-Profile/Transport/https-uajson-openapi` (OPC Foundation [profile/2338](https://profiles.opcfoundation.org/profile/2338); surfaced via `Profiles.HttpsOpenApiTransport`)
- `SecurityMode = None` (TLS provides transport security)
- `SecurityPolicyUri = None`
- `ServerCertificate` and `UserIdentityTokens` copied from the
companion HTTPS-binary description so clients can pick a compatible
identity at activate time.
Clients discover the binding through `GetEndpoints` and resolve it by
the `TransportProfileUri` — the synthetic registry-key scheme
`opc.https+webapi` (`Utils.UriSchemeOpcHttpsWebApi`) maps the profile
to the `WebApiTransportChannelFactory` while the wire-level URL stays
`https://...`. The fluent shortcut
`ManagedSessionBuilder.UseWebApiEndpoint(url)` constructs the
endpoint description with all of these fields pre-populated.
> The companion WebSocket sub-profile
> `Profiles.WssOpenApiTransport` (OPC Foundation [profile/2339](https://profiles.opcfoundation.org/profile/2339))
> is implemented as a peer to the HTTPS surface:
> `WebApiWssTransportChannel` on the client side,
> `HttpsTransportListener.AcceptWebSocketOpenApiAsync` on the server
> side. Fluent shortcut:
> `ManagedSessionBuilder.UseWssOpenApiEndpoint(url, encoding)`. Bearer
> tokens ride in the sub-protocol name
> (`opcua+openapi+<accesstoken>`) because browser WebSocket APIs forbid
> custom HTTP request headers.
>
> **Security considerations for the bearer-prefix sub-protocol:**
> the WebSocket spec requires the server to echo the selected
> sub-protocol back to the client in the 101 handshake. The bearer
> token therefore appears in plain text in:
> 1. The 101 response on the wire (rejected by the client when the
> URL scheme is not `wss://`; rejected by the server when the
> request is not `IsHttps`).
> 2. The server's Kestrel access log (`Sec-WebSocket-Protocol` header).
> 3. Any HTTP proxy / WAF / load-balancer log on the path.
>
> Operators **must** redact the `Sec-WebSocket-Protocol` header from
> logs and **should** use short-lived tokens (≤ 60 s TTL) so a
> captured token expires before it can be replayed. The server also
> requires `AddWebApiBearerAuth(...)` to be registered: without a
> bearer validator the listener fail-closed rejects every
> bearer-prefix upgrade rather than echo the token back.
## Wire format
The body is the bare `<Service>Request` / `<Service>Response` object —
**no `{UaTypeId, UaBody}` envelope** at the HTTPS layer. Body
serialization uses the stack's existing `JsonEncoder` /
`JsonDecoder` driven by `WebApiBodyCodec`. The encoder shape matches
the spec's component schemas property-for-property; see
`WebApiEncoderConformanceTests` in
`tests/Opc.Ua.Core.Tests/Stack/WebApi/`.
## Authentication
Four auth modes are pluggable via DI:
```csharp
services.AddOpcUa()
.AddHttpsTransport()
.AddWebApiTransport()
.AddWebApiAnonymousAuth() // baseline
.AddWebApiBearerAuth(opt => { opt.Authority = "..."; }) // JWT
.AddWebApiBasicAuth(async (user, password) => { /* ... */ }) // RFC 7617
.AddWebApiMutualTlsAuth(opt => { opt.AllowedCertificateTypes = ... });
```
- **Anonymous** — no `Authorization` header; identity =
`AnonymousIdentityToken`.
- **Bearer JWT** — standard
`Microsoft.AspNetCore.Authentication.JwtBearer` middleware; the JWT
flows into the OPC UA dispatcher through
`ISessionlessIdentityProvider`.
- **HTTP Basic** — in-package
`BasicAuthenticationHandler`. Rejects requests over plain HTTP by
default (`Options.RequireHttps = true`).
- **Mutual TLS** —
`Microsoft.AspNetCore.Authentication.Certificate` against the
client cert presented to Kestrel. Kestrel itself must be configured
to request client certificates (the existing
`HttpsSettings.HttpsMutualTls` flag is the supported path).
For sessionless services (Read/Write/Browse/…) the resolved
`IUserIdentity` is attached to the
`WebApiInvocationContext.Identity` and flows into the
dispatcher's role-based-access pipeline through
`ISessionlessIdentityProvider`. For session-based services the
client is responsible for calling `CreateSession` + `ActivateSession`
with credentials in the request body — the binding does not
double-authenticate.
The `WebApiHttpsStartupContributor` automatically inserts
`app.UseAuthentication()` between `UseRouting()` and `UseEndpoints()`
whenever at least one auth scheme is registered (detected via
`IOptions<AuthenticationOptions>.Schemes`), so the
`HttpContext.User` is populated before the dispatcher invokes the
sessionless identity provider. Bindings registered without any
`AddWebApi*Auth()` call skip the authentication middleware entirely
to preserve the historical anonymous request flow.
The default `ISessionlessIdentityProvider` is intentionally
conservative; register a custom implementation to map richer
claim sets (e.g. JWT role claims → OPC UA roles).
### JWT claim projection (built-in)
For services configured with `Microsoft.AspNetCore.Authentication.JwtBearer`,
the binding ships a built-in identity provider that projects the
authenticated JWT principal onto an OPC UA `IUserIdentity`:
```csharp
services.AddOpcUa()
.AddHttpsTransport()
.AddWebApiTransport()
.AddWebApiBearerAuth(opt => { opt.Authority = "..."; })
.UseJwtClaimIdentityProvider(opt =>
{
opt.SubjectClaim = "sub"; // default
opt.ScopeClaim = "scope"; // default — space- or comma-separated
opt.RolesClaim = "roles"; // default — also picks up ClaimTypes.Role
opt.ReturnAnonymousForUnauthenticated = false; // default
opt.TransformIdentity = (identity, ctx) => identity; // optional hook
});
```
The provider builds a `UserIdentity` carrying the raw bearer token as
an `IssuedIdentityToken` (`Profiles.JwtUserToken`) so server-side
authenticators can re-validate the JWT downstream, and exposes a
projection of the subject / scopes / roles via the static helpers
`JwtClaimSessionlessIdentityProvider.GetSubject(IUserIdentity)`,
`GetScopes(IUserIdentity)`, and `GetRoles(IUserIdentity)` for
role-based-access lookups. Scopes accept either the single
space-separated OAuth 2.0 form or repeated `scope` claims; roles
accept comma/semicolon/space-separated values plus
`ClaimTypes.Role` mappings.
## Hosting modes
```csharp
services.AddWebApiTransport(opt =>
{
opt.HostingMode = WebApiHostingMode.SharedWithHttpsListener; // default
opt.DefaultEncoding = WebApiEncoding.Compact; // spec default
});
```
- **`SharedWithHttpsListener`** (default) — Minimal-API endpoints
mount into the existing `HttpsTransportListener` Kestrel pipeline
via the internal `IHttpsListenerStartupContributor` hook. Single
port for binary / `opcua+uajson` / REST.
## Long-poll `/publish`
`PublishRequest`/`PublishResponse` flow through the same dispatcher
the binary path uses. The server side awaits the next notification
(or the request's `RequestHeader.TimeoutHint`) without blocking a
thread. Kestrel timeouts must exceed the largest expected
`TimeoutHint`:
```csharp
webHostBuilder.UseKestrel(o =>
{
o.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(5);
o.Limits.RequestBodyTimeout = TimeSpan.FromMinutes(5);
});
```
On the client, set `WebApiClientOptions.RequestTimeout =
Timeout.InfiniteTimeSpan` for the publish endpoint so the server-side
`TimeoutHint` governs cancellation.
## Client integration
The OPC Foundation publishes reference WebApi clients for several
ecosystems:
- TypeScript: <https://github.com/OPCFoundation/opcua-webapi-typescript>
- Python: <https://github.com/OPCFoundation/opcua-webapi-python>
- .NET: <https://github.com/OPCFoundation/opcua-webapi-dotnet>
On the .NET stack this binding's REST surface is wired into
`ManagedSession` via the fluent
`ManagedSessionBuilder.UseWebApiEndpoint(url, encoding)` shortcut, so
applications open and operate a WebApi session through the same
`ISession` / subscription / monitored-item API that the binary
transport uses:
```csharp
using Opc.Ua.Client;
await using ManagedSession session = await new ManagedSessionBuilder(telemetry)
.UseWebApiEndpoint("https://server:4843/")
.WithSessionName("Aot-Sample-Client")
.WithUserIdentity(new UserIdentity())
.StartAsync(ct)
.ConfigureAwait(false);
ReadResponse response = await session.ReadAsync(new ReadRequest
{
RequestHeader = new RequestHeader { TimeoutHint = 10000 },
NodesToRead = new ArrayOf<ReadValueId>(/* … */)
}, ct).ConfigureAwait(false);
```
The companion `UseWssOpenApiEndpoint(url)` shortcut binds the same
session model to the WebSocket `opcua+openapi` sub-protocol.
## Related plans and follow-ups
- **Source-generated OpenAPI document** (deferred) — the spec's
`opc.ua.openapi.allservices.json` document and a runtime `/openapi/v1.json`
endpoint will land in a future PR; the current binding produces
spec-shaped JSON bodies without needing the document itself.