Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb92878e3c | ||
|
|
53fa44da26 | ||
|
|
3c3edf4c6f | ||
|
|
1cbe03b49e | ||
|
|
26cba3398f |
@@ -4,6 +4,19 @@ All notable changes to the published WIP Rust crates are documented here.
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## 0.2.0
|
||||||
|
|
||||||
|
- Adds public Text View `Token`/`TokenKind`, `tokenize_object`,
|
||||||
|
`tokenize_interface`, and delimiter-free `to_plain_text`. Direct rendering uses
|
||||||
|
the same token generation path; HTML and terminal-color renderers are not included.
|
||||||
|
- Makes Client Interface caches scope-lifetime-aware, invalidates observations on
|
||||||
|
confirmed binding loss, and retains immutable contexts for in-flight calls.
|
||||||
|
|
||||||
|
- Adds `wip-text-view`, an independent single-entity Object/Interface signature
|
||||||
|
library with Protocol validation, typed errors, full ordered declarations,
|
||||||
|
documentation summaries and safe Text View quoting/escapes. No path, lookup,
|
||||||
|
cache, UI, network or Protocol wire-schema changes are required.
|
||||||
|
|
||||||
- Breaking Protocol/HTTP migration from opaque Interface reference strings to
|
- Breaking Protocol/HTTP migration from opaque Interface reference strings to
|
||||||
exact `(scope, name)` records in Objects, fetches and calls, with optional
|
exact `(scope, name)` records in Objects, fetches and calls, with optional
|
||||||
opaque `scope_ref` metadata. Adds shape and placement-aware scope validation
|
opaque `scope_ref` metadata. Adds shape and placement-aware scope validation
|
||||||
|
|||||||
Generated
+16
-9
@@ -1235,7 +1235,7 @@ checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wip-client"
|
name = "wip-client"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"wip-http",
|
"wip-http",
|
||||||
"wip-protocol",
|
"wip-protocol",
|
||||||
@@ -1243,7 +1243,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wip-fs-server"
|
name = "wip-fs-server"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"http",
|
"http",
|
||||||
"rustix 1.1.5",
|
"rustix 1.1.5",
|
||||||
@@ -1256,7 +1256,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wip-host"
|
name = "wip-host"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"trybuild",
|
"trybuild",
|
||||||
"wip-interface",
|
"wip-interface",
|
||||||
@@ -1265,7 +1265,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wip-http"
|
name = "wip-http"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"http",
|
"http",
|
||||||
@@ -1277,7 +1277,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wip-interface"
|
name = "wip-interface"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"trybuild",
|
"trybuild",
|
||||||
"wip-interface-macros",
|
"wip-interface-macros",
|
||||||
@@ -1286,7 +1286,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wip-interface-macros"
|
name = "wip-interface-macros"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -1295,11 +1295,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wip-protocol"
|
name = "wip-protocol"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wip-public-api-example"
|
name = "wip-public-api-example"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"wip-client",
|
"wip-client",
|
||||||
"wip-host",
|
"wip-host",
|
||||||
@@ -1308,9 +1308,16 @@ dependencies = [
|
|||||||
"wip-protocol",
|
"wip-protocol",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wip-text-view"
|
||||||
|
version = "0.2.0"
|
||||||
|
dependencies = [
|
||||||
|
"wip-protocol",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wip-tui"
|
name = "wip-tui"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
|
|||||||
+9
-8
@@ -3,17 +3,18 @@ members = ["crates/*"]
|
|||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.88"
|
rust-version = "1.88"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
repository = "https://gitea.hareworks.net/Hare/wip-rs"
|
repository = "https://gitea.hareworks.net/Hare/wip-rs"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
wip-client = { version = "0.1.0", path = "crates/wip-client" }
|
wip-client = { version = "0.2.0", path = "crates/wip-client" }
|
||||||
wip-fs-server = { version = "0.1.0", path = "crates/wip-fs-server" }
|
wip-fs-server = { version = "0.2.0", path = "crates/wip-fs-server" }
|
||||||
wip-host = { version = "0.1.0", path = "crates/wip-host" }
|
wip-host = { version = "0.2.0", path = "crates/wip-host" }
|
||||||
wip-http = { version = "0.1.0", path = "crates/wip-http" }
|
wip-http = { version = "0.2.0", path = "crates/wip-http" }
|
||||||
wip-interface = { version = "0.1.0", path = "crates/wip-interface" }
|
wip-interface = { version = "0.2.0", path = "crates/wip-interface" }
|
||||||
wip-interface-macros = { version = "0.1.0", path = "crates/wip-interface-macros" }
|
wip-interface-macros = { version = "0.2.0", path = "crates/wip-interface-macros" }
|
||||||
wip-protocol = { version = "0.1.0", path = "crates/wip-protocol" }
|
wip-protocol = { version = "0.2.0", path = "crates/wip-protocol" }
|
||||||
|
wip-text-view = { version = "0.2.0", path = "crates/wip-text-view" }
|
||||||
|
|||||||
+42
-1
@@ -1,6 +1,47 @@
|
|||||||
# Structured Interface reference and scope publication migration
|
# Structured Interface reference and scope publication migration
|
||||||
|
|
||||||
This unreleased API/wire change follows `wip-reference` canonical Core Model,
|
## Transport-independent Client API
|
||||||
|
|
||||||
|
The default `wip-client` feature set still exposes the HTTP-compatible
|
||||||
|
`wip_client::Client` and `PreparedRequest`. Its constructor takes state and wire
|
||||||
|
limits, sessions canonicalize HTTP endpoints, and `complete` consumes an HTTP
|
||||||
|
response. Existing TUI and native HTTP consumers need no source migration; the
|
||||||
|
wire contract is unchanged.
|
||||||
|
|
||||||
|
For an in-process Host (including Client and Host in one WASM module), disable
|
||||||
|
default features and use `wip_client::direct::{Client, Request, Response}`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
wip-client = { version = "0.2.0", default-features = false }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `direct::Client::new(ClientLimits)` takes no wire limits.
|
||||||
|
- `open_session(worldspace_identity, SecurityContext)` accepts an opaque identity,
|
||||||
|
not a URL. Issue a separate identity per Worldspace view/Host lifetime and a
|
||||||
|
separate security identity whenever authorization context changes. Direct and
|
||||||
|
canonical HTTP session identities cannot alias each other's cache.
|
||||||
|
- Preparation methods retain their names and arguments. Their result is
|
||||||
|
`direct::PreparedRequest { id, request: Request }`, with `Observe`,
|
||||||
|
`FetchInterface`, and `CallOperation` variants containing Protocol request DTOs.
|
||||||
|
- Route that exact DTO to the session's Host/security context; mark calls
|
||||||
|
dispatched before invoking the Host. Complete with the original `id` and the
|
||||||
|
matching `Response` variant containing `Result<Protocol DTO, ProtocolError>`.
|
||||||
|
No HTTP request, response, JSON codec, or artificial endpoint is needed.
|
||||||
|
- Direct responses undergo the same semantic validation and state transitions
|
||||||
|
as HTTP responses. Wrong interactions and invalid results are rejected;
|
||||||
|
invalid call responses retain an unknown execution outcome. Retrievals may
|
||||||
|
be explicitly refreshed, but operations are never automatically retried.
|
||||||
|
- `ClientLimits` still bounds cache, sessions, in-flight requests, and history.
|
||||||
|
HTTP status/envelope checks and byte/JSON nesting limits belong to the optional
|
||||||
|
HTTP adapter. An embedding application owns DTO allocation/execution budgets.
|
||||||
|
|
||||||
|
The `direct` module API documentation contains a runnable typed example. Run
|
||||||
|
`cargo test -p wip-client --no-default-features` to exercise the core without an
|
||||||
|
HTTP dependency; ordinary `cargo test -p wip-client` also runs unchanged HTTP
|
||||||
|
regressions and direct/HTTP state parity tests.
|
||||||
|
|
||||||
|
|
||||||
|
The 0.2.0 API/wire change follows `wip-reference` canonical Core Model,
|
||||||
Protocol and WIP over HTTP at `6086f3c5ef10aa1464ce9c824750d7f667217bfd`.
|
Protocol and WIP over HTTP at `6086f3c5ef10aa1464ce9c824750d7f667217bfd`.
|
||||||
It replaces the earlier opaque Interface reference contract; HTTP routes and
|
It replaces the earlier opaque Interface reference contract; HTTP routes and
|
||||||
error codes have **not** changed. Peers must upgrade together: the codec does
|
error codes have **not** changed. Peers must upgrade together: the codec does
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
# wip-rs
|
# wip-rs
|
||||||
|
|
||||||
Rust building blocks for the Web Interface Protocol (WIP).
|
Rust building blocks for the Worldspace Interface Protocol (WIP).
|
||||||
|
|
||||||
The repository is a resolver-3 virtual Cargo workspace whose packages all live
|
The repository is a resolver-3 virtual Cargo workspace whose packages all live
|
||||||
under `crates/*`. It provides a transport-independent protocol model, the
|
under `crates/*`. It provides a transport-independent protocol model, the
|
||||||
standard framework-independent WIP over HTTP v1 binding, a stateful
|
standard framework-independent WIP over HTTP v1 binding, a stateful
|
||||||
presentation-independent client runtime, a Rust interface-authoring facade, a
|
presentation-independent client runtime, a Rust interface-authoring facade, a
|
||||||
request-scoped host runtime, a bounded Linux filesystem projection server, an
|
request-scoped host runtime, a bounded Linux filesystem projection server, an
|
||||||
interactive terminal client, and a private public-API conformance consumer. The
|
interactive terminal client, a standalone Text View signature library, and a
|
||||||
|
private public-API conformance consumer. The
|
||||||
repository root is not itself a package.
|
repository root is not itself a package.
|
||||||
|
|
||||||
## Crate graph and responsibilities
|
## Crate graph and responsibilities
|
||||||
@@ -15,6 +16,9 @@ repository root is not itself a package.
|
|||||||
```text
|
```text
|
||||||
Published libraries
|
Published libraries
|
||||||
|
|
||||||
|
wip-text-view
|
||||||
|
└── wip-protocol
|
||||||
|
|
||||||
wip-client
|
wip-client
|
||||||
├── wip-http
|
├── wip-http
|
||||||
│ └── wip-protocol
|
│ └── wip-protocol
|
||||||
@@ -47,19 +51,20 @@ wip-public-api-example
|
|||||||
|
|
||||||
| Crate | Responsibility | Does not own |
|
| Crate | Responsibility | Does not own |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| `wip-text-view` | Standalone, validated single-Object and single-Interface Text View signatures with full declarations, summaries and safe escapes. | Retrieval, cache, placement context, wire schema, UI layout, parsing or call-target reconstruction. |
|
||||||
| `wip-protocol` | Transport-independent descriptors, object/call/result types, logical `Value`s, validation, and stable protocol error categories. | Application DTOs, implementations, persistence, or wire encoding. |
|
| `wip-protocol` | Transport-independent descriptors, object/call/result types, logical `Value`s, validation, and stable protocol error categories. | Application DTOs, implementations, persistence, or wire encoding. |
|
||||||
| `wip-http` | Standard WIP over HTTP v1 endpoint/route derivation, UTF-8 JSON codecs, direct success bodies, and error/status classification. | HTTP I/O, URL scheme or TLS policy, authentication schemes, server frameworks, filesystem projection, or client presentation/cache policy. |
|
| `wip-http` | Standard WIP over HTTP v1 endpoint/route derivation, UTF-8 JSON codecs, direct success bodies, and error/status classification. | HTTP I/O, URL scheme or TLS policy, authentication schemes, server frameworks, filesystem projection, or client presentation/cache policy. |
|
||||||
| `wip-client` | Endpoint/security-context-isolated Known Space, normalized object and interface observations, intent-driven object and descriptor coverage with bounded request deduplication, immutable operation call contexts, stale transitions, and bounded histories. | HTTP I/O, credentials, persistent cache, automatic operation retries, or presentation widgets/events. |
|
| `wip-client` | Endpoint/security-context-isolated Known Space, normalized object and interface observations, intent-driven object and descriptor coverage with bounded request deduplication, immutable operation call contexts, stale transitions, and bounded histories. | HTTP I/O, credentials, persistent cache, automatic operation retries, or presentation widgets/events. |
|
||||||
| `wip-tui` | Keyboard-only interactive Worldspace tree, selected Entry and qualified operation panes, descriptor-driven argument editing, bounded result/diagnostic rendering, terminal lifecycle, and HTTP I/O workers over `wip-client`. | Independent cache/validator semantics, automatic operation retries, Host internals, backend paths, or persistent state. |
|
| `wip-tui` | Keyboard-only interactive Worldspace tree, selected Entry and qualified operation panes, descriptor-driven argument editing, bounded result/diagnostic rendering, terminal lifecycle, and HTTP I/O workers over `wip-client`. | Independent cache/validator semantics, automatic operation retries, Host internals, backend paths, or persistent state. |
|
||||||
| `wip-interface` | The public `WipType`/`interface` authoring API, Rust-to-protocol codecs, generated descriptors, and typed implementation dispatch. | Object publication, backend lifetime, or transport policy. |
|
| `wip-interface` | The public `WipType`/`interface` authoring API, Rust-to-protocol codecs, generated descriptors, and typed implementation dispatch. | Object publication, backend lifetime, or transport policy. |
|
||||||
| `wip-interface-macros` | Procedural-macro expansion used by `wip-interface`. It is not an application dependency. | A supported direct user API. |
|
| `wip-interface-macros` | Procedural-macro expansion used by `wip-interface`. It is not an application dependency. | A supported direct user API. |
|
||||||
| `wip-host` | Opaque interface registration, explicit object membership, validator checks, interface-qualified dispatch against one host-owned instance, and descriptor-guided argument decoding inside that same resolution boundary. | Backend atomicity, global object lookup, or network serving. |
|
| `wip-host` | Scoped interface registration and publication lifetimes, explicit object membership, validator checks, interface-qualified dispatch against one host-owned instance, and descriptor-guided argument decoding inside that same resolution boundary. | Backend atomicity, global object lookup, or network serving. |
|
||||||
| `wip-fs-server` | Linux directory-handle-confined request-time projection, complete depth-bounded observations and bounded file operations, standard `wip-http` dispatch, and a loopback HTTP binary. | A new wire profile, implicit symlink following, non-loopback cleartext, authentication policy, TLS termination, or filesystem-wide preregistration. |
|
| `wip-fs-server` | Linux directory-handle-confined request-time projection, complete depth-bounded observations and bounded file operations, standard `wip-http` dispatch, and a loopback HTTP binary. | A new wire profile, implicit symlink following, non-loopback cleartext, authentication policy, TLS termination, or filesystem-wide preregistration. |
|
||||||
| `wip-public-api-example` | An independently compiled consumer of the protocol, authoring, and host public crates. Its DTOs and traits are application-owned; its implementation and backend handle remain host-private. | Reusable library API or a production server. |
|
| `wip-public-api-example` | An independently compiled consumer of the protocol, authoring, and host public crates. Its DTOs and traits are application-owned; its implementation and backend handle remain host-private. | Reusable library API or a production server. |
|
||||||
|
|
||||||
## Published crates
|
## Published crates
|
||||||
|
|
||||||
The `0.1.0` release consists of these six crates:
|
The `0.2.0` release consists of these seven crates:
|
||||||
|
|
||||||
- `wip-protocol`
|
- `wip-protocol`
|
||||||
- `wip-interface-macros` — implementation dependency re-exported by `wip-interface`
|
- `wip-interface-macros` — implementation dependency re-exported by `wip-interface`
|
||||||
@@ -67,6 +72,13 @@ The `0.1.0` release consists of these six crates:
|
|||||||
- `wip-host`
|
- `wip-host`
|
||||||
- `wip-http`
|
- `wip-http`
|
||||||
- `wip-client`
|
- `wip-client`
|
||||||
|
- `wip-text-view`
|
||||||
|
|
||||||
|
The [`wip-text-view`](crates/wip-text-view/README.md) library's
|
||||||
|
`render_object(&Object)` API needs no path;
|
||||||
|
`render_interface(&Interface)` takes one borrowed entity pairing the complete
|
||||||
|
structured reference with its descriptor, without changing Protocol wire fields.
|
||||||
|
Both return one complete plain-text signature or a typed error.
|
||||||
|
|
||||||
The filesystem server, terminal client, and public-API example are workspace-only packages and set `publish = false`. All workspace packages require Rust 1.88 or newer and are licensed under either Apache-2.0 or MIT at your option.
|
The filesystem server, terminal client, and public-API example are workspace-only packages and set `publish = false`. All workspace packages require Rust 1.88 or newer and are licensed under either Apache-2.0 or MIT at your option.
|
||||||
|
|
||||||
@@ -81,7 +93,7 @@ or application traits.
|
|||||||
|
|
||||||
## Structured Interface references
|
## Structured Interface references
|
||||||
|
|
||||||
The unreleased Protocol/HTTP API uses explicit `InterfaceReference { scope,
|
The 0.2.0 Protocol/HTTP API uses explicit `InterfaceReference { scope,
|
||||||
name }` records and optional `scope_ref` observation/precondition metadata.
|
name }` records and optional `scope_ref` observation/precondition metadata.
|
||||||
Legacy opaque-string wire references are rejected. See [MIGRATION.md](MIGRATION.md)
|
Legacy opaque-string wire references are rejected. See [MIGRATION.md](MIGRATION.md)
|
||||||
for construction examples, validation boundaries and downstream migration status.
|
for construction examples, validation boundaries and downstream migration status.
|
||||||
|
|||||||
+8
-5
@@ -1,12 +1,12 @@
|
|||||||
# Releasing
|
# Releasing
|
||||||
|
|
||||||
The six crates published from this workspace use one version and must be released in dependency order. The filesystem server, terminal client, and public-API example set `publish = false` and are never uploaded.
|
The seven public crates in the current workspace (including `wip-text-view`) use one version and must be released in dependency order. The filesystem server, terminal client, and public-API example set `publish = false` and are never uploaded.
|
||||||
|
|
||||||
## Preconditions
|
## Preconditions
|
||||||
|
|
||||||
1. Work from a clean `master` whose `HEAD` matches `origin/master`.
|
1. Work from a clean `master` whose `HEAD` matches `origin/master`.
|
||||||
2. Confirm the intended version in `[workspace.package]` and every internal requirement in `[workspace.dependencies]`.
|
2. Confirm the intended version in `[workspace.package]` and every internal requirement in `[workspace.dependencies]`.
|
||||||
3. Confirm ownership of all six names on crates.io.
|
3. Confirm ownership of all seven names on crates.io.
|
||||||
4. Run the complete repository validation suite:
|
4. Run the complete repository validation suite:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
@@ -44,6 +44,9 @@ cargo publish --package wip-interface-macros
|
|||||||
Wait until both versions are available from the crates.io index, then continue:
|
Wait until both versions are available from the crates.io index, then continue:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
|
cargo publish --dry-run --package wip-text-view
|
||||||
|
cargo publish --package wip-text-view
|
||||||
|
|
||||||
cargo publish --dry-run --package wip-interface
|
cargo publish --dry-run --package wip-interface
|
||||||
cargo publish --package wip-interface
|
cargo publish --package wip-interface
|
||||||
|
|
||||||
@@ -65,11 +68,11 @@ Registry propagation can take time. A dependent dry-run reporting that a WIP cra
|
|||||||
|
|
||||||
## Tag
|
## Tag
|
||||||
|
|
||||||
After all six uploads resolve successfully, tag the exact published commit and push the tag:
|
After all seven uploads resolve successfully, tag the exact published commit and push the tag:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
git tag -a v0.1.0 -m "release: 0.1.0"
|
git tag -a v0.2.0 -m "chore: release 0.2.0"
|
||||||
git push origin v0.1.0
|
git push origin v0.2.0
|
||||||
```
|
```
|
||||||
|
|
||||||
Do not move or recreate a published version or its tag. Prepare a new version for any subsequent correction.
|
Do not move or recreate a published version or its tag. Prepare a new version for any subsequent correction.
|
||||||
|
|||||||
@@ -9,6 +9,18 @@ repository.workspace = true
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
documentation = "https://docs.rs/wip-client"
|
documentation = "https://docs.rs/wip-client"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["http"]
|
||||||
|
http = ["dep:wip-http"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
wip-http.workspace = true
|
wip-http = { workspace = true, optional = true }
|
||||||
wip-protocol.workspace = true
|
wip-protocol.workspace = true
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "adapter"
|
||||||
|
required-features = ["http"]
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "runtime"
|
||||||
|
required-features = ["http"]
|
||||||
|
|||||||
@@ -1,15 +1,31 @@
|
|||||||
# wip-client
|
# wip-client
|
||||||
|
|
||||||
Presentation-independent stateful runtime for Web Interface Protocol (WIP) clients.
|
Presentation-independent stateful runtime for Worldspace Interface Protocol (WIP) clients.
|
||||||
|
|
||||||
`wip-client` manages endpoint- and security-context-isolated Known Space, object and Interface Descriptor observations, bounded request ownership, supersession, out-of-order response suppression, validator recovery, and operation call contexts. It produces and consumes framework-neutral `wip-http` messages but performs no network I/O and contains no UI types.
|
`wip-client` manages Worldspace-view- and security-context-isolated Known Space, object and Interface Descriptor observations, bounded request ownership, supersession, out-of-order response suppression, validator recovery, and operation call contexts. Its typed logical core requires neither HTTP nor JSON. The default HTTP facade produces and consumes framework-neutral `wip-http` messages using the same logical runtime. Neither path performs network I/O or contains UI types.
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
wip-client = "0.1.0"
|
wip-client = "0.2.0"
|
||||||
```
|
```
|
||||||
|
|
||||||
Applications provide the HTTP adapter and presentation layer, send each prepared request exactly as owned by the runtime, then return the corresponding response for completion.
|
The default `http` feature preserves `wip_client::Client::new(state_limits, wire_limits)`, canonical HTTP endpoint sessions, `PreparedRequest`, and HTTP `complete`. Applications send each prepared request and return its corresponding response.
|
||||||
|
|
||||||
|
## Direct typed Client (no HTTP dependency)
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
wip-client = { version = "0.2.0", default-features = false }
|
||||||
|
wip-protocol = "0.2.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `wip_client::direct::{Client, Request, Response}`. Construct the client with `Client::new(state_limits)` and open a session using an opaque, application-owned Worldspace view identity plus `SecurityContext`; no URL is parsed or required. Allocate different identities for different Hosts/views and when replacing a Host. Direct identities and HTTP endpoint identities occupy distinct namespaces, even when their strings match.
|
||||||
|
|
||||||
|
The existing observation, interface acquisition, and call preparation methods return `direct::PreparedRequest { id, request }`. Dispatch the `Request::Observe`, `Request::FetchInterface`, or `Request::CallOperation` DTO to the Host, then call `complete(id, Response::<matching interaction>(result))`, where `result` is `Result<Protocol response DTO, ProtocolError>`. The prepared request must be dispatched unchanged; routing it to the session's Host/security context remains the application's responsibility. Mark calls dispatched immediately before invoking the Host.
|
||||||
|
|
||||||
|
Typed results are **not trusted**: the runtime checks interaction correspondence, observation depth/names/paths, scoped Interface correspondence, descriptor structure and format, descriptor-bound arguments/results, and per-interaction Protocol error codes. Invalid call responses leave the outcome unknown, just as with HTTP. HTTP status/envelope classification, JSON encoding, and wire size/depth limits remain exclusive to the optional adapter; `ClientLimits` bounds retained logical state on both paths. Direct DTO allocation and execution budgets belong to the embedding application.
|
||||||
|
|
||||||
|
See [migration guidance](../../MIGRATION.md#transport-independent-client-api) and the `direct` module's executable API example.
|
||||||
|
|
||||||
## Interface identity and scope lifetime
|
## Interface identity and scope lifetime
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
//! Optional HTTP facade. Encoding and wire classification live only here.
|
||||||
|
use std::ops::{Deref, DerefMut};
|
||||||
|
|
||||||
|
use wip_http::http::{Request, Response};
|
||||||
|
use wip_http::{
|
||||||
|
ClientResponseError, DecodedResponse, Endpoint, Limits, StatusMismatch,
|
||||||
|
decode_call_operation_response, decode_fetch_interface_response, decode_observe_response,
|
||||||
|
encode_call_operation_request, encode_fetch_interface_request, encode_observe_request,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Encoded HTTP request paired with its runtime identity.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct PreparedRequest {
|
||||||
|
/// Identity required when completing or failing this request.
|
||||||
|
pub id: RequestId,
|
||||||
|
/// Standard WIP over HTTP request.
|
||||||
|
pub request: Request<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP-compatible facade over the typed logical client.
|
||||||
|
///
|
||||||
|
/// State inspection and transport failure reporting delegate to the same core.
|
||||||
|
/// Request encoding is performed before changing cache or in-flight state.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Client {
|
||||||
|
core: DirectClient,
|
||||||
|
wire_limits: Limits,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deref for Client {
|
||||||
|
type Target = DirectClient;
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.core
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DerefMut for Client {
|
||||||
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||||
|
&mut self.core
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
/// Creates an empty runtime with explicit state and wire bounds.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(limits: ClientLimits, wire_limits: Limits) -> Self {
|
||||||
|
Self {
|
||||||
|
core: DirectClient::new(limits),
|
||||||
|
wire_limits,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns one cached object observation.
|
||||||
|
#[must_use]
|
||||||
|
pub fn object(&self, session: &SessionId, path: &str) -> Option<ObjectObservation> {
|
||||||
|
self.core.object(session, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns bounded Known Space in canonical path order.
|
||||||
|
#[must_use]
|
||||||
|
pub fn known_space(&self, session: &SessionId) -> Option<Vec<ObjectObservation>> {
|
||||||
|
self.core.known_space(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns one materialized lazy-tree node.
|
||||||
|
#[must_use]
|
||||||
|
pub fn tree(&self, session: &SessionId, path: &str) -> Option<&TreeObservation> {
|
||||||
|
self.core.tree(session, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the descriptor observation for a complete scoped reference.
|
||||||
|
#[must_use]
|
||||||
|
pub fn interface(
|
||||||
|
&self,
|
||||||
|
session: &SessionId,
|
||||||
|
reference: &InterfaceReference,
|
||||||
|
) -> Option<&InterfaceObservation> {
|
||||||
|
self.core.interface(session, reference)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns bounded operation history from oldest to newest.
|
||||||
|
#[must_use]
|
||||||
|
pub fn call_history(&self, session: &SessionId) -> Option<&VecDeque<CallRecord>> {
|
||||||
|
self.core.call_history(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns bounded diagnostic history from oldest to newest.
|
||||||
|
#[must_use]
|
||||||
|
pub fn diagnostics(&self, session: &SessionId) -> Option<&VecDeque<Diagnostic>> {
|
||||||
|
self.core.diagnostics(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of requests currently in flight.
|
||||||
|
#[must_use]
|
||||||
|
pub fn in_flight_len(&self) -> usize {
|
||||||
|
self.core.in_flight_len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the immutable context of an in-flight operation.
|
||||||
|
#[must_use]
|
||||||
|
pub fn pending_call_context(&self, id: RequestId) -> Option<&CallContext> {
|
||||||
|
self.core.pending_call_context(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks an operation as handed to the transport.
|
||||||
|
pub fn mark_dispatched(&mut self, id: RequestId) -> Result<(), ClientError> {
|
||||||
|
self.core.mark_dispatched(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records a failure with no response, preserving the dispatch boundary.
|
||||||
|
pub fn fail_transport(
|
||||||
|
&mut self,
|
||||||
|
id: RequestId,
|
||||||
|
detail: impl Into<String>,
|
||||||
|
) -> Result<Completion, ClientError> {
|
||||||
|
self.core.fail_transport(id, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records timeout or disconnect after dispatch without retrying.
|
||||||
|
pub fn fail_dispatched_call(
|
||||||
|
&mut self,
|
||||||
|
id: RequestId,
|
||||||
|
failure: DispatchedTransportFailure,
|
||||||
|
detail: impl Into<String>,
|
||||||
|
) -> Result<Completion, ClientError> {
|
||||||
|
self.core.fail_dispatched_call(id, failure, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens a session isolated by canonical endpoint and security context.
|
||||||
|
pub fn open_session(
|
||||||
|
&mut self,
|
||||||
|
endpoint: &str,
|
||||||
|
security_context: SecurityContext,
|
||||||
|
) -> Result<SessionId, ClientError> {
|
||||||
|
let endpoint = Endpoint::parse(endpoint).map_err(invalid_request)?;
|
||||||
|
self.core.open_session_id(SessionId {
|
||||||
|
endpoint: endpoint.to_string(),
|
||||||
|
security_context,
|
||||||
|
binding: SessionBinding::Http,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensures requested coverage without duplicating fresh or loading work.
|
||||||
|
pub fn ensure_observed(
|
||||||
|
&mut self,
|
||||||
|
session: &SessionId,
|
||||||
|
path: impl Into<String>,
|
||||||
|
depth: u32,
|
||||||
|
) -> Result<Option<PreparedRequest>, ClientError> {
|
||||||
|
let path = path.into();
|
||||||
|
wip_protocol::validate_path(&path).map_err(invalid_request)?;
|
||||||
|
let coverage = observation_coverage(self.core.session(session)?, &path, depth);
|
||||||
|
if coverage != ObservationCoverage::Missing
|
||||||
|
|| self.core.pending.len() >= self.core.limits.in_flight
|
||||||
|
{
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
self.refresh_observed(session, path, depth).map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensures direct children without duplicating fresh or loading work.
|
||||||
|
pub fn ensure_children_observed(
|
||||||
|
&mut self,
|
||||||
|
session: &SessionId,
|
||||||
|
path: impl Into<String>,
|
||||||
|
) -> Result<Option<PreparedRequest>, ClientError> {
|
||||||
|
self.ensure_observed(session, path, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes one object without replacing previously observed children.
|
||||||
|
pub fn refresh_object(
|
||||||
|
&mut self,
|
||||||
|
session: &SessionId,
|
||||||
|
path: impl Into<String>,
|
||||||
|
) -> Result<PreparedRequest, ClientError> {
|
||||||
|
self.refresh_observed(session, path, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes one object and its direct children.
|
||||||
|
pub fn refresh_children(
|
||||||
|
&mut self,
|
||||||
|
session: &SessionId,
|
||||||
|
path: impl Into<String>,
|
||||||
|
) -> Result<PreparedRequest, ClientError> {
|
||||||
|
self.refresh_observed(session, path, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicitly refreshes observation coverage, superseding older requests.
|
||||||
|
pub fn refresh_observed(
|
||||||
|
&mut self,
|
||||||
|
session: &SessionId,
|
||||||
|
path: impl Into<String>,
|
||||||
|
depth: u32,
|
||||||
|
) -> Result<PreparedRequest, ClientError> {
|
||||||
|
let endpoint = endpoint(session)?;
|
||||||
|
let limits = self.wire_limits;
|
||||||
|
let (id, request) = self
|
||||||
|
.core
|
||||||
|
.refresh_observed_with(session, path, depth, |request| {
|
||||||
|
encode_observe_request(&endpoint, request, limits).map_err(invalid_request)
|
||||||
|
})?;
|
||||||
|
Ok(PreparedRequest { id, request })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensures an interface without duplicating work or evicting another descriptor.
|
||||||
|
pub fn ensure_interface(
|
||||||
|
&mut self,
|
||||||
|
session: &SessionId,
|
||||||
|
reference: InterfaceReference,
|
||||||
|
) -> Result<Option<PreparedRequest>, ClientError> {
|
||||||
|
let state = self.core.session(session)?;
|
||||||
|
if state.interfaces.contains_key(&reference)
|
||||||
|
|| state.interfaces.len() >= self.core.limits.interfaces_per_session
|
||||||
|
|| self.core.pending.len() >= self.core.limits.in_flight
|
||||||
|
{
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
self.prepare_interface(session, reference).map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicitly starts or refreshes a descriptor observation.
|
||||||
|
pub fn prepare_interface(
|
||||||
|
&mut self,
|
||||||
|
session: &SessionId,
|
||||||
|
reference: InterfaceReference,
|
||||||
|
) -> Result<PreparedRequest, ClientError> {
|
||||||
|
let endpoint = endpoint(session)?;
|
||||||
|
let limits = self.wire_limits;
|
||||||
|
let (id, request) = self
|
||||||
|
.core
|
||||||
|
.prepare_interface_with(session, reference, |request| {
|
||||||
|
encode_fetch_interface_request(&endpoint, request, limits).map_err(invalid_request)
|
||||||
|
})?;
|
||||||
|
Ok(PreparedRequest { id, request })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constructs an operation using fresh observations and an immutable CallContext.
|
||||||
|
pub fn prepare_call(
|
||||||
|
&mut self,
|
||||||
|
session: &SessionId,
|
||||||
|
path: &str,
|
||||||
|
interface: &InterfaceReference,
|
||||||
|
operation: impl Into<String>,
|
||||||
|
arguments: BTreeMap<String, Value>,
|
||||||
|
) -> Result<PreparedRequest, ClientError> {
|
||||||
|
let endpoint = endpoint(session)?;
|
||||||
|
let limits = self.wire_limits;
|
||||||
|
let (id, request) = self.core.prepare_call_with(
|
||||||
|
session,
|
||||||
|
path,
|
||||||
|
interface,
|
||||||
|
operation,
|
||||||
|
arguments,
|
||||||
|
|request, descriptor| {
|
||||||
|
encode_call_operation_request(&endpoint, request, descriptor, limits)
|
||||||
|
.map_err(invalid_request)
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
Ok(PreparedRequest { id, request })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes HTTP metadata and body, then validates and applies the logical result.
|
||||||
|
pub fn complete(
|
||||||
|
&mut self,
|
||||||
|
id: RequestId,
|
||||||
|
response: Response<Vec<u8>>,
|
||||||
|
) -> Result<Completion, ClientError> {
|
||||||
|
let pending = self.core.pending.get(&id).ok_or_else(unknown_request)?;
|
||||||
|
let session = pending.session().clone();
|
||||||
|
let mut mismatch = None;
|
||||||
|
let decoded = match pending {
|
||||||
|
Pending::Observe { request, .. } => {
|
||||||
|
decode_observe_response(request, &response, self.wire_limits)
|
||||||
|
.map(|value| LogicalResponse::Observe(unpack(value, &mut mismatch)))
|
||||||
|
}
|
||||||
|
Pending::Interface { request, .. } => {
|
||||||
|
decode_fetch_interface_response(request, &response, self.wire_limits)
|
||||||
|
.map(|value| LogicalResponse::FetchInterface(unpack(value, &mut mismatch)))
|
||||||
|
}
|
||||||
|
Pending::Call { context, .. } => decode_call_operation_response(
|
||||||
|
&context.request,
|
||||||
|
&context.descriptor,
|
||||||
|
&response,
|
||||||
|
self.wire_limits,
|
||||||
|
)
|
||||||
|
.map(|value| LogicalResponse::CallOperation(unpack(value, &mut mismatch))),
|
||||||
|
}
|
||||||
|
.map_err(classify_response_error);
|
||||||
|
let completion = self.core.complete_result(id, decoded)?;
|
||||||
|
if !matches!(completion, Completion::StaleResponseRejected(_))
|
||||||
|
&& let Some(mismatch) = mismatch
|
||||||
|
{
|
||||||
|
let maximum = self.core.limits.history;
|
||||||
|
self.core.session_mut(&session)?.push_diagnostic(
|
||||||
|
Diagnostic {
|
||||||
|
request_id: id,
|
||||||
|
kind: DiagnosticKind::HttpStatusMismatch,
|
||||||
|
detail: format!(
|
||||||
|
"received HTTP {}; canonical status is {}",
|
||||||
|
mismatch.actual, mismatch.expected
|
||||||
|
),
|
||||||
|
},
|
||||||
|
maximum,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(completion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn endpoint(session: &SessionId) -> Result<Endpoint, ClientError> {
|
||||||
|
if session.binding != SessionBinding::Http {
|
||||||
|
return Err(invalid_request(
|
||||||
|
"direct Worldspace session cannot be used as an HTTP endpoint",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Endpoint::parse(&session.endpoint).map_err(invalid_request)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unpack<T>(
|
||||||
|
value: DecodedResponse<T>,
|
||||||
|
mismatch: &mut Option<StatusMismatch>,
|
||||||
|
) -> Result<T, ProtocolError> {
|
||||||
|
match value {
|
||||||
|
DecodedResponse::Success(value) => Ok(value),
|
||||||
|
DecodedResponse::ProtocolFailure {
|
||||||
|
error,
|
||||||
|
status_mismatch,
|
||||||
|
} => {
|
||||||
|
*mismatch = status_mismatch;
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_response_error(error: ClientResponseError) -> ClientError {
|
||||||
|
let kind = match error {
|
||||||
|
ClientResponseError::InvalidResponse { .. } => ClientErrorKind::InvalidResponse,
|
||||||
|
ClientResponseError::UnsupportedDescriptorFormat { .. } => {
|
||||||
|
ClientErrorKind::UnsupportedDescriptorFormat
|
||||||
|
}
|
||||||
|
ClientResponseError::TransportBinding(_) => ClientErrorKind::Transport,
|
||||||
|
};
|
||||||
|
ClientError::new(kind, error.to_string())
|
||||||
|
}
|
||||||
+196
-151
@@ -1,28 +1,65 @@
|
|||||||
//! Presentation-independent stateful runtime for WIP clients.
|
//! Stateful WIP client with a transport-independent logical core.
|
||||||
//!
|
//!
|
||||||
//! [`Client`] separates observations by canonical WIP over HTTP endpoint and caller-
|
//! Use [`direct::Client`] for typed in-process interactions without HTTP or JSON.
|
||||||
//! supplied security context. It reconciles requested observation coverage without
|
//! The default `http` feature retains the original `Client` HTTP facade.
|
||||||
//! duplicating fresh or loading work, creates requests with the standard
|
//! Both paths share bounded state, validation, correlation, and Scope lifetime handling.
|
||||||
//! [`wip_http`] adapter, correlates responses by [`RequestId`], and retains
|
|
||||||
//! bounded object, tree, interface, diagnostic, and operation state. The crate
|
|
||||||
//! contains no terminal, widget, or UI event types.
|
|
||||||
|
|
||||||
#![deny(missing_docs)]
|
#![deny(missing_docs)]
|
||||||
|
|
||||||
|
mod validation;
|
||||||
|
use validation::{invalid_request, validate_call, validate_interface, validate_observe};
|
||||||
|
|
||||||
use std::collections::{BTreeMap, VecDeque};
|
use std::collections::{BTreeMap, VecDeque};
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::fmt::{self, Display, Formatter};
|
use std::fmt::{self, Display, Formatter};
|
||||||
|
|
||||||
use wip_http::http::{Request, Response};
|
#[cfg(feature = "http")]
|
||||||
use wip_http::{
|
mod http_adapter;
|
||||||
ClientResponseError, DecodedResponse, Endpoint, Limits, decode_call_operation_response,
|
#[cfg(feature = "http")]
|
||||||
decode_fetch_interface_response, decode_observe_response, encode_call_operation_request,
|
pub use http_adapter::{Client, PreparedRequest};
|
||||||
encode_fetch_interface_request, encode_observe_request,
|
|
||||||
};
|
/// Typed, transport-independent client API. Available without default features.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use wip_client::direct::{Client, Request, Response};
|
||||||
|
/// use wip_client::{ClientLimits, Completion, SecurityContext};
|
||||||
|
/// use wip_protocol::{Object, ObjectObservation};
|
||||||
|
///
|
||||||
|
/// let mut client = Client::new(ClientLimits::new(2, 32, 16, 8, 16)?);
|
||||||
|
/// let session = client.open_session("embedded-world-v1", SecurityContext::new("reader"))?;
|
||||||
|
/// let prepared = client.refresh_object(&session, "/")?;
|
||||||
|
/// let Request::Observe(request) = prepared.request else { unreachable!() };
|
||||||
|
/// assert_eq!(request.path, "/");
|
||||||
|
/// // An in-process Host returns this DTO directly, without HTTP or JSON.
|
||||||
|
/// let result = Ok(ObjectObservation {
|
||||||
|
/// object: Object {
|
||||||
|
/// name: String::new(), description: None, interfaces: vec![],
|
||||||
|
/// r#ref: None, validator: None,
|
||||||
|
/// },
|
||||||
|
/// children: None,
|
||||||
|
/// });
|
||||||
|
/// let completion = client.complete(prepared.id, Response::Observe(result))?;
|
||||||
|
/// assert!(matches!(completion, Completion::Object(_)));
|
||||||
|
/// # Ok::<(), Box<dyn std::error::Error>>(())
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// `FetchInterface` and `CallOperation` use the corresponding request/response
|
||||||
|
/// variants. Dispatch each prepared request unchanged to its session's Host and
|
||||||
|
/// security context. Call [`DirectClient::mark_dispatched`] immediately before invoking
|
||||||
|
/// an Operation. Typed responses are untrusted and validated against the pending
|
||||||
|
/// request and immutable CallContext; the runtime never automatically retries.
|
||||||
|
pub mod direct {
|
||||||
|
pub use super::{
|
||||||
|
DirectClient as Client, DirectPreparedRequest as PreparedRequest,
|
||||||
|
LogicalRequest as Request, LogicalResponse as Response,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
use wip_protocol::{
|
use wip_protocol::{
|
||||||
CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, InterfaceDescriptor,
|
CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, FetchInterfaceResponse,
|
||||||
InterfaceReference, InterfaceTarget, Object, ObjectObservation as ProtocolObjectObservation,
|
INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceReference, InterfaceTarget, Object,
|
||||||
ObserveRequest, OperationDeclaration, ProtocolError, ProtocolErrorCode, Target, Value,
|
ObjectObservation as ProtocolObjectObservation, ObserveRequest, OperationDeclaration,
|
||||||
|
ProtocolError, ProtocolErrorCode, ProtocolInteraction, Target, Value,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Monotonic identity assigned to one logical request.
|
/// Monotonic identity assigned to one logical request.
|
||||||
@@ -58,15 +95,23 @@ impl SecurityContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
enum SessionBinding {
|
||||||
|
Direct,
|
||||||
|
#[cfg(feature = "http")]
|
||||||
|
Http,
|
||||||
|
}
|
||||||
|
|
||||||
/// Identity of an isolated client session.
|
/// Identity of an isolated client session.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
pub struct SessionId {
|
pub struct SessionId {
|
||||||
endpoint: String,
|
endpoint: String,
|
||||||
security_context: SecurityContext,
|
security_context: SecurityContext,
|
||||||
|
binding: SessionBinding,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionId {
|
impl SessionId {
|
||||||
/// Returns the canonical endpoint URL, including its trailing slash.
|
/// Returns the canonical HTTP endpoint or application-defined direct Worldspace identity.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn endpoint(&self) -> &str {
|
pub fn endpoint(&self) -> &str {
|
||||||
&self.endpoint
|
&self.endpoint
|
||||||
@@ -368,13 +413,35 @@ pub struct Diagnostic {
|
|||||||
pub detail: String,
|
pub detail: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encoded HTTP request paired with its runtime identity.
|
/// Typed request paired with its runtime identity.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct PreparedRequest {
|
pub struct DirectPreparedRequest {
|
||||||
/// Identity required when completing or failing this request.
|
/// Identity required when completing or failing this request.
|
||||||
pub id: RequestId,
|
pub id: RequestId,
|
||||||
/// Standard WIP over HTTP request.
|
/// Logical interaction to dispatch to the selected Worldspace.
|
||||||
pub request: Request<Vec<u8>>,
|
pub request: LogicalRequest,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A typed request with no transport encoding.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum LogicalRequest {
|
||||||
|
/// Observe an Object and requested descendants.
|
||||||
|
Observe(ObserveRequest),
|
||||||
|
/// Fetch a scoped Interface descriptor.
|
||||||
|
FetchInterface(FetchInterfaceRequest),
|
||||||
|
/// Invoke an Operation using immutable prepared inputs.
|
||||||
|
CallOperation(CallOperationRequest),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An untrusted typed result. Every completion is validated against its request.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum LogicalResponse {
|
||||||
|
/// Observe success or Protocol failure.
|
||||||
|
Observe(Result<ProtocolObjectObservation, ProtocolError>),
|
||||||
|
/// Interface success or Protocol failure.
|
||||||
|
FetchInterface(Result<FetchInterfaceResponse, ProtocolError>),
|
||||||
|
/// Operation success or Protocol failure.
|
||||||
|
CallOperation(Result<CallOperationResponse, ProtocolError>),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of applying a correlated response to the runtime.
|
/// Result of applying a correlated response to the runtime.
|
||||||
@@ -553,40 +620,43 @@ impl Pending {
|
|||||||
|
|
||||||
/// Stateful, presentation-independent WIP client runtime.
|
/// Stateful, presentation-independent WIP client runtime.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Client {
|
pub struct DirectClient {
|
||||||
limits: ClientLimits,
|
limits: ClientLimits,
|
||||||
wire_limits: Limits,
|
|
||||||
sessions: BTreeMap<SessionId, Session>,
|
sessions: BTreeMap<SessionId, Session>,
|
||||||
pending: BTreeMap<RequestId, Pending>,
|
pending: BTreeMap<RequestId, Pending>,
|
||||||
next_request_id: u64,
|
next_request_id: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Client {
|
impl DirectClient {
|
||||||
/// Creates an empty runtime with explicit state and wire bounds.
|
/// Creates an empty logical runtime with explicit state bounds.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(limits: ClientLimits, wire_limits: Limits) -> Self {
|
pub fn new(limits: ClientLimits) -> Self {
|
||||||
Self {
|
Self {
|
||||||
limits,
|
limits,
|
||||||
wire_limits,
|
|
||||||
sessions: BTreeMap::new(),
|
sessions: BTreeMap::new(),
|
||||||
pending: BTreeMap::new(),
|
pending: BTreeMap::new(),
|
||||||
next_request_id: 1,
|
next_request_id: 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens or returns a session isolated by canonical endpoint and security context.
|
/// Opens or returns a session isolated by Worldspace view and security context.
|
||||||
|
///
|
||||||
|
/// The identity is opaque, not a URL. Use different identities for different
|
||||||
|
/// Worldspace views and replace it when an in-process Host is replaced.
|
||||||
|
/// No endpoint parsing, HTTP binding, or JSON codec is involved.
|
||||||
pub fn open_session(
|
pub fn open_session(
|
||||||
&mut self,
|
&mut self,
|
||||||
endpoint: &str,
|
endpoint: &str,
|
||||||
security_context: SecurityContext,
|
security_context: SecurityContext,
|
||||||
) -> Result<SessionId, ClientError> {
|
) -> Result<SessionId, ClientError> {
|
||||||
let endpoint = Endpoint::parse(endpoint).map_err(|error| {
|
self.open_session_id(SessionId {
|
||||||
ClientError::new(ClientErrorKind::InvalidRequest, error.to_string())
|
endpoint: endpoint.to_owned(),
|
||||||
})?;
|
|
||||||
let id = SessionId {
|
|
||||||
endpoint: endpoint.to_string(),
|
|
||||||
security_context,
|
security_context,
|
||||||
};
|
binding: SessionBinding::Direct,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_session_id(&mut self, id: SessionId) -> Result<SessionId, ClientError> {
|
||||||
if !self.sessions.contains_key(&id) {
|
if !self.sessions.contains_key(&id) {
|
||||||
if self.sessions.len() >= self.limits.sessions {
|
if self.sessions.len() >= self.limits.sessions {
|
||||||
return Err(ClientError::new(
|
return Err(ClientError::new(
|
||||||
@@ -679,7 +749,7 @@ impl Client {
|
|||||||
session: &SessionId,
|
session: &SessionId,
|
||||||
path: impl Into<String>,
|
path: impl Into<String>,
|
||||||
depth: u32,
|
depth: u32,
|
||||||
) -> Result<Option<PreparedRequest>, ClientError> {
|
) -> Result<Option<DirectPreparedRequest>, ClientError> {
|
||||||
let path = path.into();
|
let path = path.into();
|
||||||
wip_protocol::validate_path(&path).map_err(|error| {
|
wip_protocol::validate_path(&path).map_err(|error| {
|
||||||
ClientError::new(ClientErrorKind::InvalidRequest, error.to_string())
|
ClientError::new(ClientErrorKind::InvalidRequest, error.to_string())
|
||||||
@@ -697,7 +767,7 @@ impl Client {
|
|||||||
&mut self,
|
&mut self,
|
||||||
session: &SessionId,
|
session: &SessionId,
|
||||||
path: impl Into<String>,
|
path: impl Into<String>,
|
||||||
) -> Result<Option<PreparedRequest>, ClientError> {
|
) -> Result<Option<DirectPreparedRequest>, ClientError> {
|
||||||
self.ensure_observed(session, path, 1)
|
self.ensure_observed(session, path, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -707,7 +777,7 @@ impl Client {
|
|||||||
&mut self,
|
&mut self,
|
||||||
session: &SessionId,
|
session: &SessionId,
|
||||||
path: impl Into<String>,
|
path: impl Into<String>,
|
||||||
) -> Result<PreparedRequest, ClientError> {
|
) -> Result<DirectPreparedRequest, ClientError> {
|
||||||
self.refresh_observed(session, path, 0)
|
self.refresh_observed(session, path, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -716,30 +786,40 @@ impl Client {
|
|||||||
&mut self,
|
&mut self,
|
||||||
session: &SessionId,
|
session: &SessionId,
|
||||||
path: impl Into<String>,
|
path: impl Into<String>,
|
||||||
) -> Result<PreparedRequest, ClientError> {
|
) -> Result<DirectPreparedRequest, ClientError> {
|
||||||
self.refresh_observed(session, path, 1)
|
self.refresh_observed(session, path, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Explicitly starts or refreshes an object observation through `depth`.
|
/// Explicitly starts or refreshes an object observation through `depth`.
|
||||||
///
|
///
|
||||||
/// Unlike [`Client::ensure_observed`], this always creates a new request and
|
/// Unlike [`DirectClient::ensure_observed`], this always creates a new request and
|
||||||
/// supersedes older requests for the same root path.
|
/// supersedes older requests for the same root path.
|
||||||
pub fn refresh_observed(
|
pub fn refresh_observed(
|
||||||
&mut self,
|
&mut self,
|
||||||
session: &SessionId,
|
session: &SessionId,
|
||||||
path: impl Into<String>,
|
path: impl Into<String>,
|
||||||
depth: u32,
|
depth: u32,
|
||||||
) -> Result<PreparedRequest, ClientError> {
|
) -> Result<DirectPreparedRequest, ClientError> {
|
||||||
|
let (id, request) = self.refresh_observed_with(session, path, depth, |request| {
|
||||||
|
Ok(LogicalRequest::Observe(request.clone()))
|
||||||
|
})?;
|
||||||
|
Ok(DirectPreparedRequest { id, request })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_observed_with<T>(
|
||||||
|
&mut self,
|
||||||
|
session: &SessionId,
|
||||||
|
path: impl Into<String>,
|
||||||
|
depth: u32,
|
||||||
|
encode: impl FnOnce(&ObserveRequest) -> Result<T, ClientError>,
|
||||||
|
) -> Result<(RequestId, T), ClientError> {
|
||||||
self.ensure_capacity()?;
|
self.ensure_capacity()?;
|
||||||
let request = ObserveRequest {
|
let request = ObserveRequest {
|
||||||
path: path.into(),
|
path: path.into(),
|
||||||
depth,
|
depth,
|
||||||
};
|
};
|
||||||
let endpoint = session_endpoint(session)?;
|
request.validate().map_err(invalid_request)?;
|
||||||
let encoded =
|
let encoded = encode(&request)?;
|
||||||
encode_observe_request(&endpoint, &request, self.wire_limits).map_err(|error| {
|
|
||||||
ClientError::new(ClientErrorKind::InvalidRequest, error.to_string())
|
|
||||||
})?;
|
|
||||||
let id = self.allocate_request_id();
|
let id = self.allocate_request_id();
|
||||||
let maximum = self.limits.objects_per_session;
|
let maximum = self.limits.objects_per_session;
|
||||||
let state = self.session_mut(session)?;
|
let state = self.session_mut(session)?;
|
||||||
@@ -782,10 +862,7 @@ impl Client {
|
|||||||
request,
|
request,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
Ok(PreparedRequest {
|
Ok((id, encoded))
|
||||||
id,
|
|
||||||
request: encoded,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensures that an interface descriptor has been observed without issuing
|
/// Ensures that an interface descriptor has been observed without issuing
|
||||||
@@ -800,7 +877,7 @@ impl Client {
|
|||||||
&mut self,
|
&mut self,
|
||||||
session: &SessionId,
|
session: &SessionId,
|
||||||
reference: InterfaceReference,
|
reference: InterfaceReference,
|
||||||
) -> Result<Option<PreparedRequest>, ClientError> {
|
) -> Result<Option<DirectPreparedRequest>, ClientError> {
|
||||||
let state = self.session(session)?;
|
let state = self.session(session)?;
|
||||||
if state.interfaces.contains_key(&reference)
|
if state.interfaces.contains_key(&reference)
|
||||||
|| state.interfaces.len() >= self.limits.interfaces_per_session
|
|| state.interfaces.len() >= self.limits.interfaces_per_session
|
||||||
@@ -813,22 +890,31 @@ impl Client {
|
|||||||
|
|
||||||
/// Explicitly starts or refreshes an interface descriptor observation.
|
/// Explicitly starts or refreshes an interface descriptor observation.
|
||||||
///
|
///
|
||||||
/// Unlike [`Client::ensure_interface`], this always creates a new request and
|
/// Unlike [`DirectClient::ensure_interface`], this always creates a new request and
|
||||||
/// may evict an older non-loading descriptor when the interface cache is full.
|
/// may evict an older non-loading descriptor when the interface cache is full.
|
||||||
pub fn prepare_interface(
|
pub fn prepare_interface(
|
||||||
&mut self,
|
&mut self,
|
||||||
session: &SessionId,
|
session: &SessionId,
|
||||||
reference: InterfaceReference,
|
reference: InterfaceReference,
|
||||||
) -> Result<PreparedRequest, ClientError> {
|
) -> Result<DirectPreparedRequest, ClientError> {
|
||||||
|
let (id, request) = self.prepare_interface_with(session, reference, |request| {
|
||||||
|
Ok(LogicalRequest::FetchInterface(request.clone()))
|
||||||
|
})?;
|
||||||
|
Ok(DirectPreparedRequest { id, request })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_interface_with<T>(
|
||||||
|
&mut self,
|
||||||
|
session: &SessionId,
|
||||||
|
reference: InterfaceReference,
|
||||||
|
encode: impl FnOnce(&FetchInterfaceRequest) -> Result<T, ClientError>,
|
||||||
|
) -> Result<(RequestId, T), ClientError> {
|
||||||
self.ensure_capacity()?;
|
self.ensure_capacity()?;
|
||||||
let request = FetchInterfaceRequest {
|
let request = FetchInterfaceRequest {
|
||||||
interface: reference,
|
interface: reference,
|
||||||
};
|
};
|
||||||
let endpoint = session_endpoint(session)?;
|
request.validate().map_err(invalid_request)?;
|
||||||
let encoded = encode_fetch_interface_request(&endpoint, &request, self.wire_limits)
|
let encoded = encode(&request)?;
|
||||||
.map_err(|error| {
|
|
||||||
ClientError::new(ClientErrorKind::InvalidRequest, error.to_string())
|
|
||||||
})?;
|
|
||||||
let id = self.allocate_request_id();
|
let id = self.allocate_request_id();
|
||||||
let maximum = self.limits.interfaces_per_session;
|
let maximum = self.limits.interfaces_per_session;
|
||||||
let state = self.session_mut(session)?;
|
let state = self.session_mut(session)?;
|
||||||
@@ -863,10 +949,7 @@ impl Client {
|
|||||||
request,
|
request,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
Ok(PreparedRequest {
|
Ok((id, encoded))
|
||||||
id,
|
|
||||||
request: encoded,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Constructs an operation from fresh object and interface observations.
|
/// Constructs an operation from fresh object and interface observations.
|
||||||
@@ -883,7 +966,27 @@ impl Client {
|
|||||||
interface: &InterfaceReference,
|
interface: &InterfaceReference,
|
||||||
operation: impl Into<String>,
|
operation: impl Into<String>,
|
||||||
arguments: BTreeMap<String, Value>,
|
arguments: BTreeMap<String, Value>,
|
||||||
) -> Result<PreparedRequest, ClientError> {
|
) -> Result<DirectPreparedRequest, ClientError> {
|
||||||
|
let (id, request) = self.prepare_call_with(
|
||||||
|
session,
|
||||||
|
path,
|
||||||
|
interface,
|
||||||
|
operation,
|
||||||
|
arguments,
|
||||||
|
|request, _| Ok(LogicalRequest::CallOperation(request.clone())),
|
||||||
|
)?;
|
||||||
|
Ok(DirectPreparedRequest { id, request })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_call_with<T>(
|
||||||
|
&mut self,
|
||||||
|
session: &SessionId,
|
||||||
|
path: &str,
|
||||||
|
interface: &InterfaceReference,
|
||||||
|
operation: impl Into<String>,
|
||||||
|
arguments: BTreeMap<String, Value>,
|
||||||
|
encode: impl FnOnce(&CallOperationRequest, &InterfaceDescriptor) -> Result<T, ClientError>,
|
||||||
|
) -> Result<(RequestId, T), ClientError> {
|
||||||
self.ensure_capacity()?;
|
self.ensure_capacity()?;
|
||||||
let operation = operation.into();
|
let operation = operation.into();
|
||||||
let context = {
|
let context = {
|
||||||
@@ -969,14 +1072,11 @@ impl Client {
|
|||||||
request,
|
request,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let endpoint = session_endpoint(session)?;
|
context
|
||||||
let encoded = encode_call_operation_request(
|
.request
|
||||||
&endpoint,
|
.validate_with(&context.descriptor)
|
||||||
&context.request,
|
.map_err(invalid_request)?;
|
||||||
&context.descriptor,
|
let encoded = encode(&context.request, &context.descriptor)?;
|
||||||
self.wire_limits,
|
|
||||||
)
|
|
||||||
.map_err(|error| ClientError::new(ClientErrorKind::InvalidRequest, error.to_string()))?;
|
|
||||||
let id = self.allocate_request_id();
|
let id = self.allocate_request_id();
|
||||||
self.session_mut(&context.session)?
|
self.session_mut(&context.session)?
|
||||||
.pin_call_object(context.object_key);
|
.pin_call_object(context.object_key);
|
||||||
@@ -987,10 +1087,7 @@ impl Client {
|
|||||||
dispatched: false,
|
dispatched: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
Ok(PreparedRequest {
|
Ok((id, encoded))
|
||||||
id,
|
|
||||||
request: encoded,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marks an operation request as handed to the transport.
|
/// Marks an operation request as handed to the transport.
|
||||||
@@ -1008,11 +1105,20 @@ impl Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Applies one HTTP response to the exact request identity that produced it.
|
/// Validates and applies a typed response to the exact request that produced it.
|
||||||
|
/// A mismatched interaction is invalid; invalid call responses have unknown outcomes.
|
||||||
pub fn complete(
|
pub fn complete(
|
||||||
&mut self,
|
&mut self,
|
||||||
id: RequestId,
|
id: RequestId,
|
||||||
response: Response<Vec<u8>>,
|
response: LogicalResponse,
|
||||||
|
) -> Result<Completion, ClientError> {
|
||||||
|
self.complete_result(id, Ok(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn complete_result(
|
||||||
|
&mut self,
|
||||||
|
id: RequestId,
|
||||||
|
response: Result<LogicalResponse, ClientError>,
|
||||||
) -> Result<Completion, ClientError> {
|
) -> Result<Completion, ClientError> {
|
||||||
let pending = self.pending.remove(&id).ok_or_else(unknown_request)?;
|
let pending = self.pending.remove(&id).ok_or_else(unknown_request)?;
|
||||||
match pending {
|
match pending {
|
||||||
@@ -1141,13 +1247,13 @@ impl Client {
|
|||||||
id: RequestId,
|
id: RequestId,
|
||||||
session: &SessionId,
|
session: &SessionId,
|
||||||
request: ObserveRequest,
|
request: ObserveRequest,
|
||||||
response: Response<Vec<u8>>,
|
response: Result<LogicalResponse, ClientError>,
|
||||||
) -> Result<Completion, ClientError> {
|
) -> Result<Completion, ClientError> {
|
||||||
if !self.entry_request_is_current(session, &request.path, id) {
|
if !self.entry_request_is_current(session, &request.path, id) {
|
||||||
return self.reject_stale(session, id);
|
return self.reject_stale(session, id);
|
||||||
}
|
}
|
||||||
match decode_observe_response(&request, &response, self.wire_limits) {
|
match validate_observe(&request, response) {
|
||||||
Ok(DecodedResponse::Success(value)) => {
|
Ok(Ok(value)) => {
|
||||||
let maximum = self.limits.objects_per_session;
|
let maximum = self.limits.objects_per_session;
|
||||||
let mut candidate = self.session(session)?.clone();
|
let mut candidate = self.session(session)?.clone();
|
||||||
if let Err(error) =
|
if let Err(error) =
|
||||||
@@ -1168,11 +1274,7 @@ impl Client {
|
|||||||
self.sessions.insert(session.clone(), candidate);
|
self.sessions.insert(session.clone(), candidate);
|
||||||
Ok(Completion::Object(observation))
|
Ok(Completion::Object(observation))
|
||||||
}
|
}
|
||||||
Ok(DecodedResponse::ProtocolFailure {
|
Ok(Err(error)) => {
|
||||||
error,
|
|
||||||
status_mismatch,
|
|
||||||
}) => {
|
|
||||||
self.record_status_mismatch(session, id, status_mismatch)?;
|
|
||||||
let observation_state = if error.code == ProtocolErrorCode::NotFound {
|
let observation_state = if error.code == ProtocolErrorCode::NotFound {
|
||||||
ObservationState::Stale
|
ObservationState::Stale
|
||||||
} else {
|
} else {
|
||||||
@@ -1205,13 +1307,13 @@ impl Client {
|
|||||||
id: RequestId,
|
id: RequestId,
|
||||||
session: &SessionId,
|
session: &SessionId,
|
||||||
request: FetchInterfaceRequest,
|
request: FetchInterfaceRequest,
|
||||||
response: Response<Vec<u8>>,
|
response: Result<LogicalResponse, ClientError>,
|
||||||
) -> Result<Completion, ClientError> {
|
) -> Result<Completion, ClientError> {
|
||||||
if !self.interface_request_is_current(session, &request.interface, id) {
|
if !self.interface_request_is_current(session, &request.interface, id) {
|
||||||
return self.reject_stale(session, id);
|
return self.reject_stale(session, id);
|
||||||
}
|
}
|
||||||
match decode_fetch_interface_response(&request, &response, self.wire_limits) {
|
match validate_interface(&request, response) {
|
||||||
Ok(DecodedResponse::Success(value)) => {
|
Ok(Ok(value)) => {
|
||||||
let state = self.session_mut(session)?;
|
let state = self.session_mut(session)?;
|
||||||
// Newer scope evidence (Observe or another Interface name) can
|
// Newer scope evidence (Observe or another Interface name) can
|
||||||
// supersede even the first fetch for this pair.
|
// supersede even the first fetch for this pair.
|
||||||
@@ -1290,11 +1392,7 @@ impl Client {
|
|||||||
record.touched = touched;
|
record.touched = touched;
|
||||||
Ok(Completion::Interface(observation))
|
Ok(Completion::Interface(observation))
|
||||||
}
|
}
|
||||||
Ok(DecodedResponse::ProtocolFailure {
|
Ok(Err(error)) => {
|
||||||
error,
|
|
||||||
status_mismatch,
|
|
||||||
}) => {
|
|
||||||
self.record_status_mismatch(session, id, status_mismatch)?;
|
|
||||||
self.session_mut(session)?
|
self.session_mut(session)?
|
||||||
.interfaces
|
.interfaces
|
||||||
.get_mut(&request.interface)
|
.get_mut(&request.interface)
|
||||||
@@ -1319,15 +1417,10 @@ impl Client {
|
|||||||
&mut self,
|
&mut self,
|
||||||
id: RequestId,
|
id: RequestId,
|
||||||
context: CallContext,
|
context: CallContext,
|
||||||
response: Response<Vec<u8>>,
|
response: Result<LogicalResponse, ClientError>,
|
||||||
) -> Result<Completion, ClientError> {
|
) -> Result<Completion, ClientError> {
|
||||||
match decode_call_operation_response(
|
match validate_call(&context, response) {
|
||||||
&context.request,
|
Ok(Ok(value)) => {
|
||||||
&context.descriptor,
|
|
||||||
&response,
|
|
||||||
self.wire_limits,
|
|
||||||
) {
|
|
||||||
Ok(DecodedResponse::Success(value)) => {
|
|
||||||
let session_id = context.session.clone();
|
let session_id = context.session.clone();
|
||||||
let maximum = self.limits.history;
|
let maximum = self.limits.history;
|
||||||
let state = self.session_mut(&session_id)?;
|
let state = self.session_mut(&session_id)?;
|
||||||
@@ -1347,12 +1440,8 @@ impl Client {
|
|||||||
state.push_call(record.clone(), maximum);
|
state.push_call(record.clone(), maximum);
|
||||||
Ok(Completion::Call(Box::new(record)))
|
Ok(Completion::Call(Box::new(record)))
|
||||||
}
|
}
|
||||||
Ok(DecodedResponse::ProtocolFailure {
|
Ok(Err(error)) => {
|
||||||
error,
|
|
||||||
status_mismatch,
|
|
||||||
}) => {
|
|
||||||
let session_id = context.session.clone();
|
let session_id = context.session.clone();
|
||||||
self.record_status_mismatch(&session_id, id, status_mismatch)?;
|
|
||||||
if error.code == ProtocolErrorCode::OperationOutcomeUnknown {
|
if error.code == ProtocolErrorCode::OperationOutcomeUnknown {
|
||||||
let maximum = self.limits.history;
|
let maximum = self.limits.history;
|
||||||
let state = self.session_mut(&session_id)?;
|
let state = self.session_mut(&session_id)?;
|
||||||
@@ -1410,7 +1499,7 @@ impl Client {
|
|||||||
Ok(Completion::Call(Box::new(record)))
|
Ok(Completion::Call(Box::new(record)))
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let failure = classify_response_error(error);
|
let failure = error;
|
||||||
self.finish_unknown_call(id, context, OutcomeUnknownReason::ResponseDecode, failure)
|
self.finish_unknown_call(id, context, OutcomeUnknownReason::ResponseDecode, failure)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1452,9 +1541,8 @@ impl Client {
|
|||||||
id: RequestId,
|
id: RequestId,
|
||||||
session: &SessionId,
|
session: &SessionId,
|
||||||
subject: RetrievalSubject,
|
subject: RetrievalSubject,
|
||||||
error: ClientResponseError,
|
error: ClientError,
|
||||||
) -> Result<Completion, ClientError> {
|
) -> Result<Completion, ClientError> {
|
||||||
let error = classify_response_error(error);
|
|
||||||
match subject {
|
match subject {
|
||||||
RetrievalSubject::Observe(request) => {
|
RetrievalSubject::Observe(request) => {
|
||||||
set_observe_subject_state(
|
set_observe_subject_state(
|
||||||
@@ -1505,29 +1593,6 @@ impl Client {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn record_status_mismatch(
|
|
||||||
&mut self,
|
|
||||||
session: &SessionId,
|
|
||||||
id: RequestId,
|
|
||||||
mismatch: Option<wip_http::StatusMismatch>,
|
|
||||||
) -> Result<(), ClientError> {
|
|
||||||
if let Some(mismatch) = mismatch {
|
|
||||||
let maximum = self.limits.history;
|
|
||||||
self.session_mut(session)?.push_diagnostic(
|
|
||||||
Diagnostic {
|
|
||||||
request_id: id,
|
|
||||||
kind: DiagnosticKind::HttpStatusMismatch,
|
|
||||||
detail: format!(
|
|
||||||
"received HTTP {}; canonical status is {}",
|
|
||||||
mismatch.actual, mismatch.expected
|
|
||||||
),
|
|
||||||
},
|
|
||||||
maximum,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push_failure_diagnostic(
|
fn push_failure_diagnostic(
|
||||||
&mut self,
|
&mut self,
|
||||||
session: &SessionId,
|
session: &SessionId,
|
||||||
@@ -1682,30 +1747,10 @@ fn observation_coverage(state: &Session, path: &str, depth: u32) -> ObservationC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn session_endpoint(session: &SessionId) -> Result<Endpoint, ClientError> {
|
|
||||||
Endpoint::parse(&session.endpoint)
|
|
||||||
.map_err(|error| ClientError::new(ClientErrorKind::InvalidRequest, error.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unknown_request() -> ClientError {
|
fn unknown_request() -> ClientError {
|
||||||
ClientError::new(ClientErrorKind::InvalidRequest, "unknown request identity")
|
ClientError::new(ClientErrorKind::InvalidRequest, "unknown request identity")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn classify_response_error(error: ClientResponseError) -> ClientError {
|
|
||||||
match error {
|
|
||||||
ClientResponseError::InvalidResponse { .. } => {
|
|
||||||
ClientError::new(ClientErrorKind::InvalidResponse, error.to_string())
|
|
||||||
}
|
|
||||||
ClientResponseError::UnsupportedDescriptorFormat { .. } => ClientError::new(
|
|
||||||
ClientErrorKind::UnsupportedDescriptorFormat,
|
|
||||||
error.to_string(),
|
|
||||||
),
|
|
||||||
ClientResponseError::TransportBinding(_) => {
|
|
||||||
ClientError::new(ClientErrorKind::Transport, error.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_observe_subject_state(
|
fn set_observe_subject_state(
|
||||||
state: &mut Session,
|
state: &mut Session,
|
||||||
request: &ObserveRequest,
|
request: &ObserveRequest,
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn invalid_response(error: impl Display) -> ClientError {
|
||||||
|
ClientError::new(ClientErrorKind::InvalidResponse, error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn invalid_request(error: impl Display) -> ClientError {
|
||||||
|
ClientError::new(ClientErrorKind::InvalidRequest, error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_failure<T>(
|
||||||
|
result: Result<T, ProtocolError>,
|
||||||
|
interaction: ProtocolInteraction,
|
||||||
|
validate: impl FnOnce(&T) -> Result<(), ClientError>,
|
||||||
|
) -> Result<Result<T, ProtocolError>, ClientError> {
|
||||||
|
match &result {
|
||||||
|
Ok(value) => validate(value)?,
|
||||||
|
Err(error) if !error.code.is_allowed_for(interaction) => {
|
||||||
|
return Err(invalid_response(
|
||||||
|
"Protocol error is forbidden for this interaction",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn validate_observe(
|
||||||
|
request: &ObserveRequest,
|
||||||
|
response: Result<LogicalResponse, ClientError>,
|
||||||
|
) -> Result<Result<ProtocolObjectObservation, ProtocolError>, ClientError> {
|
||||||
|
let LogicalResponse::Observe(result) = response? else {
|
||||||
|
return Err(invalid_response(
|
||||||
|
"response interaction does not match Observe request",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
validate_failure(result, ProtocolInteraction::Observe, |value| {
|
||||||
|
request.validate_response(value).map_err(invalid_response)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn validate_interface(
|
||||||
|
request: &FetchInterfaceRequest,
|
||||||
|
response: Result<LogicalResponse, ClientError>,
|
||||||
|
) -> Result<Result<FetchInterfaceResponse, ProtocolError>, ClientError> {
|
||||||
|
let LogicalResponse::FetchInterface(result) = response? else {
|
||||||
|
return Err(invalid_response(
|
||||||
|
"response interaction does not match FetchInterface request",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
validate_failure(result, ProtocolInteraction::FetchInterface, |value| {
|
||||||
|
value.validate_for(request).map_err(invalid_response)?;
|
||||||
|
if value.descriptor.format != INTERFACE_FORMAT_V1 {
|
||||||
|
return Err(ClientError::new(
|
||||||
|
ClientErrorKind::UnsupportedDescriptorFormat,
|
||||||
|
format!(
|
||||||
|
"unsupported interface descriptor format `{}`",
|
||||||
|
value.descriptor.format
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn validate_call(
|
||||||
|
context: &CallContext,
|
||||||
|
response: Result<LogicalResponse, ClientError>,
|
||||||
|
) -> Result<Result<CallOperationResponse, ProtocolError>, ClientError> {
|
||||||
|
let LogicalResponse::CallOperation(result) = response? else {
|
||||||
|
return Err(invalid_response(
|
||||||
|
"response interaction does not match CallOperation request",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
validate_failure(result, ProtocolInteraction::CallOperation, |value| {
|
||||||
|
value
|
||||||
|
.validate_for(&context.descriptor, &context.request.operation)
|
||||||
|
.map_err(invalid_response)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,614 @@
|
|||||||
|
//! Public API tests: no HTTP types, codec, endpoint parser, or JSON dependency.
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use wip_client::direct::{Client, PreparedRequest, Request, Response};
|
||||||
|
use wip_client::{
|
||||||
|
CallOutcome, ClientErrorKind, ClientLimits, Completion, DispatchedTransportFailure,
|
||||||
|
ObservationState, OutcomeUnknownReason, SecurityContext, SessionId,
|
||||||
|
};
|
||||||
|
use wip_protocol::{
|
||||||
|
CallOperationResponse, FetchInterfaceResponse, INTERFACE_FORMAT_V1, InterfaceDescriptor,
|
||||||
|
InterfaceReference, Object, ObjectObservation, OperationDeclaration, ParameterDeclaration,
|
||||||
|
ProtocolError, ProtocolErrorCode, ReturnDeclaration, TypeExpr, Value,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn setup() -> (Client, SessionId) {
|
||||||
|
let mut client = Client::new(ClientLimits::new(4, 16, 8, 8, 4).unwrap());
|
||||||
|
let session = client
|
||||||
|
.open_session("in-process world", SecurityContext::new("alice"))
|
||||||
|
.unwrap();
|
||||||
|
(client, session)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reference() -> InterfaceReference {
|
||||||
|
InterfaceReference {
|
||||||
|
scope: "/scope".into(),
|
||||||
|
name: "math".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn object(name: &str, identity: Option<&str>) -> ObjectObservation {
|
||||||
|
ObjectObservation {
|
||||||
|
object: Object {
|
||||||
|
name: name.into(),
|
||||||
|
description: None,
|
||||||
|
interfaces: vec![reference()],
|
||||||
|
r#ref: identity.map(str::to_owned),
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
children: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn descriptor() -> InterfaceDescriptor {
|
||||||
|
InterfaceDescriptor {
|
||||||
|
format: INTERFACE_FORMAT_V1.into(),
|
||||||
|
documentation: None,
|
||||||
|
types: vec![],
|
||||||
|
operations: vec![OperationDeclaration {
|
||||||
|
name: "double".into(),
|
||||||
|
documentation: None,
|
||||||
|
parameters: vec![ParameterDeclaration {
|
||||||
|
name: "n".into(),
|
||||||
|
documentation: None,
|
||||||
|
required: true,
|
||||||
|
r#type: TypeExpr::Integer,
|
||||||
|
}],
|
||||||
|
returns: ReturnDeclaration {
|
||||||
|
documentation: None,
|
||||||
|
r#type: TypeExpr::Integer,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interface(scope_ref: Option<&str>) -> FetchInterfaceResponse {
|
||||||
|
FetchInterfaceResponse {
|
||||||
|
interface: reference(),
|
||||||
|
scope_ref: scope_ref.map(str::to_owned),
|
||||||
|
validator: None,
|
||||||
|
descriptor: descriptor(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failure(code: ProtocolErrorCode) -> ProtocolError {
|
||||||
|
ProtocolError {
|
||||||
|
code,
|
||||||
|
message: "test failure".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn observe(client: &mut Client, session: &SessionId, path: &str, value: ObjectObservation) {
|
||||||
|
let request = client.refresh_object(session, path).unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(&request.request, Request::Observe(value) if value.path == path && value.depth == 0)
|
||||||
|
);
|
||||||
|
client
|
||||||
|
.complete(request.id, Response::Observe(Ok(value)))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ready(client: &mut Client, session: &SessionId) {
|
||||||
|
observe(client, session, "/scope/item", object("item", None));
|
||||||
|
let request = client.prepare_interface(session, reference()).unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(request.request, Request::FetchInterface(value) if value.interface == reference())
|
||||||
|
);
|
||||||
|
client
|
||||||
|
.complete(request.id, Response::FetchInterface(Ok(interface(None))))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call(client: &mut Client, session: &SessionId) -> PreparedRequest {
|
||||||
|
client
|
||||||
|
.prepare_call(
|
||||||
|
session,
|
||||||
|
"/scope/item",
|
||||||
|
&reference(),
|
||||||
|
"double",
|
||||||
|
BTreeMap::from([("n".into(), Value::Integer(3))]),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typed_interactions_succeed_with_optional_identity_and_validators_absent() {
|
||||||
|
let (mut client, session) = setup();
|
||||||
|
ready(&mut client, &session);
|
||||||
|
assert!(
|
||||||
|
client
|
||||||
|
.ensure_observed(&session, "/scope/item", 0)
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
client
|
||||||
|
.ensure_interface(&session, reference())
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
let request = call(&mut client, &session);
|
||||||
|
let Request::CallOperation(logical) = request.request else {
|
||||||
|
panic!("wrong request")
|
||||||
|
};
|
||||||
|
assert_eq!(logical.target.path, "/scope/item");
|
||||||
|
assert_eq!(logical.target.validator, None);
|
||||||
|
assert_eq!(logical.interface.scope_ref, None);
|
||||||
|
assert_eq!(logical.interface.validator, None);
|
||||||
|
assert_eq!(logical.arguments["n"], Value::Integer(3));
|
||||||
|
client.mark_dispatched(request.id).unwrap();
|
||||||
|
let result = CallOperationResponse {
|
||||||
|
result: Value::Integer(6),
|
||||||
|
validator: Some(vec![2]),
|
||||||
|
};
|
||||||
|
let completion = client
|
||||||
|
.complete(request.id, Response::CallOperation(Ok(result.clone())))
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(completion, Completion::Call(record) if record.outcome == CallOutcome::Success(result))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client.object(&session, "/scope/item").unwrap().validator,
|
||||||
|
Some(vec![2])
|
||||||
|
);
|
||||||
|
assert_eq!(client.in_flight_len(), 0);
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.complete(
|
||||||
|
request.id,
|
||||||
|
Response::CallOperation(Ok(CallOperationResponse {
|
||||||
|
result: Value::Integer(6),
|
||||||
|
validator: None,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.kind,
|
||||||
|
ClientErrorKind::InvalidRequest
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worldspace_and_security_identities_partition_observations_without_urls() {
|
||||||
|
let (mut client, session) = setup();
|
||||||
|
ready(&mut client, &session);
|
||||||
|
let other_world = client
|
||||||
|
.open_session("another world", SecurityContext::new("alice"))
|
||||||
|
.unwrap();
|
||||||
|
let other_security = client
|
||||||
|
.open_session("in-process world", SecurityContext::new("bob"))
|
||||||
|
.unwrap();
|
||||||
|
for other in [&other_world, &other_security] {
|
||||||
|
assert!(client.object(other, "/scope/item").is_none());
|
||||||
|
assert!(client.interface(other, &reference()).is_none());
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.open_session("in-process world", SecurityContext::new("alice"))
|
||||||
|
.unwrap(),
|
||||||
|
session
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typed_observations_validate_name_depth_duplicate_children_and_interface_scope() {
|
||||||
|
let (mut client, session) = setup();
|
||||||
|
let mut wrong_scope = object("item", None);
|
||||||
|
wrong_scope.object.interfaces[0].scope = "/elsewhere".into();
|
||||||
|
let mut unexpected_children = object("item", None);
|
||||||
|
unexpected_children.children = Some(vec![]);
|
||||||
|
let mut duplicate = object("item", None);
|
||||||
|
duplicate.children = Some(vec![object("child", None), object("child", None)]);
|
||||||
|
for (depth, response) in [
|
||||||
|
(0, object("wrong-name", None)),
|
||||||
|
(1, object("item", None)),
|
||||||
|
(0, unexpected_children),
|
||||||
|
(0, wrong_scope),
|
||||||
|
(1, duplicate),
|
||||||
|
] {
|
||||||
|
let request = client
|
||||||
|
.refresh_observed(&session, "/scope/item", depth)
|
||||||
|
.unwrap();
|
||||||
|
let error = client
|
||||||
|
.complete(request.id, Response::Observe(Ok(response)))
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error.kind, ClientErrorKind::InvalidResponse);
|
||||||
|
assert!(matches!(
|
||||||
|
client.object(&session, "/scope/item").unwrap().state,
|
||||||
|
ObservationState::Error(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typed_descriptor_correspondence_structure_and_format_are_checked() {
|
||||||
|
let (mut client, session) = setup();
|
||||||
|
let mut mismatched = interface(None);
|
||||||
|
mismatched.interface.name = "other".into();
|
||||||
|
let mut malformed = interface(None);
|
||||||
|
malformed
|
||||||
|
.descriptor
|
||||||
|
.operations
|
||||||
|
.push(malformed.descriptor.operations[0].clone());
|
||||||
|
let mut unsupported = interface(None);
|
||||||
|
unsupported.descriptor.format = "future".into();
|
||||||
|
for (value, kind) in [
|
||||||
|
(mismatched, ClientErrorKind::InvalidResponse),
|
||||||
|
(malformed, ClientErrorKind::InvalidResponse),
|
||||||
|
(unsupported, ClientErrorKind::UnsupportedDescriptorFormat),
|
||||||
|
] {
|
||||||
|
let request = client.prepare_interface(&session, reference()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.complete(request.id, Response::FetchInterface(Ok(value)))
|
||||||
|
.unwrap_err()
|
||||||
|
.kind,
|
||||||
|
kind
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typed_arguments_and_results_use_the_fixed_descriptor() {
|
||||||
|
let (mut client, session) = setup();
|
||||||
|
ready(&mut client, &session);
|
||||||
|
let invalid = client
|
||||||
|
.prepare_call(
|
||||||
|
&session,
|
||||||
|
"/scope/item",
|
||||||
|
&reference(),
|
||||||
|
"double",
|
||||||
|
BTreeMap::from([("n".into(), Value::String("not integer".into()))]),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(invalid.kind, ClientErrorKind::InvalidRequest);
|
||||||
|
assert_eq!(client.in_flight_len(), 0);
|
||||||
|
let request = call(&mut client, &session);
|
||||||
|
client.mark_dispatched(request.id).unwrap();
|
||||||
|
let completion = client
|
||||||
|
.complete(
|
||||||
|
request.id,
|
||||||
|
Response::CallOperation(Ok(CallOperationResponse {
|
||||||
|
result: Value::String("not integer".into()),
|
||||||
|
validator: None,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(completion, Completion::Call(record) if matches!(record.outcome,
|
||||||
|
CallOutcome::Unknown { reason: OutcomeUnknownReason::ResponseDecode, .. }))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client.object(&session, "/scope/item").unwrap().state,
|
||||||
|
ObservationState::Stale
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
client
|
||||||
|
.ensure_observed(&session, "/scope/item", 0)
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert_eq!(client.in_flight_len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typed_protocol_failures_validate_interaction_and_apply_recovery() {
|
||||||
|
let (mut client, session) = setup();
|
||||||
|
let request = client.refresh_object(&session, "/scope/item").unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
client
|
||||||
|
.complete(
|
||||||
|
request.id,
|
||||||
|
Response::Observe(Err(failure(ProtocolErrorCode::NotFound)))
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
Completion::ProtocolFailure(_)
|
||||||
|
));
|
||||||
|
let request = client.prepare_interface(&session, reference()).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
client
|
||||||
|
.complete(
|
||||||
|
request.id,
|
||||||
|
Response::FetchInterface(Err(failure(ProtocolErrorCode::InterfaceNotFound)))
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
Completion::ProtocolFailure(_)
|
||||||
|
));
|
||||||
|
let request = client.refresh_object(&session, "/scope/item").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.complete(
|
||||||
|
request.id,
|
||||||
|
Response::Observe(Err(failure(ProtocolErrorCode::OperationOutcomeUnknown)))
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.kind,
|
||||||
|
ClientErrorKind::InvalidResponse
|
||||||
|
);
|
||||||
|
let request = client.prepare_interface(&session, reference()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.complete(
|
||||||
|
request.id,
|
||||||
|
Response::FetchInterface(Err(failure(ProtocolErrorCode::ValidatorMismatch)))
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.kind,
|
||||||
|
ClientErrorKind::InvalidResponse
|
||||||
|
);
|
||||||
|
ready(&mut client, &session);
|
||||||
|
let request = call(&mut client, &session);
|
||||||
|
let completion = client
|
||||||
|
.complete(
|
||||||
|
request.id,
|
||||||
|
Response::CallOperation(Err(failure(ProtocolErrorCode::InterfaceMismatch))),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(completion, Completion::Call(record) if matches!(record.outcome, CallOutcome::ProtocolFailure(_)))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client.object(&session, "/scope/item").unwrap().state,
|
||||||
|
ObservationState::Stale
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client.interface(&session, &reference()).unwrap().state,
|
||||||
|
ObservationState::Stale
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mismatched_interactions_are_not_trusted_and_calls_remain_unknown() {
|
||||||
|
let (mut client, session) = setup();
|
||||||
|
let request = client.refresh_object(&session, "/scope/item").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.complete(request.id, Response::FetchInterface(Ok(interface(None))))
|
||||||
|
.unwrap_err()
|
||||||
|
.kind,
|
||||||
|
ClientErrorKind::InvalidResponse
|
||||||
|
);
|
||||||
|
let request = client.prepare_interface(&session, reference()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.complete(request.id, Response::Observe(Ok(object("item", None))))
|
||||||
|
.unwrap_err()
|
||||||
|
.kind,
|
||||||
|
ClientErrorKind::InvalidResponse
|
||||||
|
);
|
||||||
|
ready(&mut client, &session);
|
||||||
|
let request = call(&mut client, &session);
|
||||||
|
let completion = client
|
||||||
|
.complete(
|
||||||
|
request.id,
|
||||||
|
Response::Observe(Err(failure(ProtocolErrorCode::NotFound))),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(completion, Completion::Call(record) if matches!(record.outcome, CallOutcome::Unknown { .. }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typed_stale_responses_cannot_replace_new_observations() {
|
||||||
|
let (mut client, session) = setup();
|
||||||
|
let old = client.refresh_object(&session, "/scope/item").unwrap();
|
||||||
|
assert!(
|
||||||
|
client
|
||||||
|
.ensure_observed(&session, "/scope/item", 0)
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
let new = client.refresh_object(&session, "/scope/item").unwrap();
|
||||||
|
client
|
||||||
|
.complete(new.id, Response::Observe(Ok(object("item", Some("new")))))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.complete(old.id, Response::Observe(Ok(object("item", Some("old")))))
|
||||||
|
.unwrap(),
|
||||||
|
Completion::StaleResponseRejected(old.id)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.object(&session, "/scope/item")
|
||||||
|
.unwrap()
|
||||||
|
.object
|
||||||
|
.unwrap()
|
||||||
|
.r#ref
|
||||||
|
.as_deref(),
|
||||||
|
Some("new")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_replacement_retires_descriptors_and_pending_fetch_but_not_call_snapshot() {
|
||||||
|
let (mut client, session) = setup();
|
||||||
|
observe(
|
||||||
|
&mut client,
|
||||||
|
&session,
|
||||||
|
"/scope",
|
||||||
|
object("scope", Some("old")),
|
||||||
|
);
|
||||||
|
ready(&mut client, &session);
|
||||||
|
let call = call(&mut client, &session);
|
||||||
|
let fetch = client.prepare_interface(&session, reference()).unwrap();
|
||||||
|
observe(
|
||||||
|
&mut client,
|
||||||
|
&session,
|
||||||
|
"/scope",
|
||||||
|
object("scope", Some("new")),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
client
|
||||||
|
.interface(&session, &reference())
|
||||||
|
.unwrap()
|
||||||
|
.descriptor
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.complete(
|
||||||
|
fetch.id,
|
||||||
|
Response::FetchInterface(Ok(interface(Some("old"))))
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
Completion::StaleResponseRejected(fetch.id)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client.pending_call_context(call.id).unwrap().descriptor(),
|
||||||
|
&descriptor()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.pending_call_context(call.id)
|
||||||
|
.unwrap()
|
||||||
|
.request()
|
||||||
|
.interface
|
||||||
|
.scope_ref,
|
||||||
|
None
|
||||||
|
);
|
||||||
|
// A newer descriptor has a different return type; the prepared call still uses Integer.
|
||||||
|
let fetch = client.prepare_interface(&session, reference()).unwrap();
|
||||||
|
let mut next = interface(Some("new"));
|
||||||
|
next.descriptor.operations[0].returns.r#type = TypeExpr::String;
|
||||||
|
client
|
||||||
|
.complete(fetch.id, Response::FetchInterface(Ok(next)))
|
||||||
|
.unwrap();
|
||||||
|
let completion = client
|
||||||
|
.complete(
|
||||||
|
call.id,
|
||||||
|
Response::CallOperation(Ok(CallOperationResponse {
|
||||||
|
result: Value::Integer(6),
|
||||||
|
validator: None,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(completion, Completion::Call(record) if matches!(record.outcome, CallOutcome::Success(_)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_not_found_retires_interfaces_and_late_fetch_cannot_restore_them() {
|
||||||
|
let (mut client, session) = setup();
|
||||||
|
ready(&mut client, &session);
|
||||||
|
let old = client.prepare_interface(&session, reference()).unwrap();
|
||||||
|
let missing = client.refresh_object(&session, "/scope").unwrap();
|
||||||
|
client
|
||||||
|
.complete(
|
||||||
|
missing.id,
|
||||||
|
Response::Observe(Err(failure(ProtocolErrorCode::NotFound))),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(client.interface(&session, &reference()).is_none());
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.complete(old.id, Response::FetchInterface(Ok(interface(None))))
|
||||||
|
.unwrap(),
|
||||||
|
Completion::StaleResponseRejected(old.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failures_before_and_after_dispatch_never_retry_operations() {
|
||||||
|
let (mut client, session) = setup();
|
||||||
|
ready(&mut client, &session);
|
||||||
|
let before = call(&mut client, &session);
|
||||||
|
assert!(
|
||||||
|
matches!(client.fail_transport(before.id, "not handed to Host").unwrap(),
|
||||||
|
Completion::Call(record) if matches!(record.outcome, CallOutcome::NotDispatched(_)))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client.object(&session, "/scope/item").unwrap().state,
|
||||||
|
ObservationState::Fresh
|
||||||
|
);
|
||||||
|
let after = call(&mut client, &session);
|
||||||
|
client.mark_dispatched(after.id).unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(client.fail_dispatched_call(after.id, DispatchedTransportFailure::Timeout, "deadline").unwrap(),
|
||||||
|
Completion::Call(record) if matches!(record.outcome, CallOutcome::Unknown { reason: OutcomeUnknownReason::Timeout, .. }))
|
||||||
|
);
|
||||||
|
assert_eq!(client.in_flight_len(), 0);
|
||||||
|
assert!(
|
||||||
|
client
|
||||||
|
.ensure_observed(&session, "/scope/item", 0)
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
ready(&mut client, &session);
|
||||||
|
let unknown = call(&mut client, &session);
|
||||||
|
assert!(
|
||||||
|
matches!(client.complete(unknown.id, Response::CallOperation(Err(failure(ProtocolErrorCode::OperationOutcomeUnknown)))).unwrap(),
|
||||||
|
Completion::Call(record) if matches!(record.outcome, CallOutcome::Unknown { reason: OutcomeUnknownReason::Protocol, .. }))
|
||||||
|
);
|
||||||
|
assert_eq!(client.in_flight_len(), 0);
|
||||||
|
assert_eq!(client.call_history(&session).unwrap().len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn direct_limits_bound_sessions_inflight_cache_and_history() {
|
||||||
|
let mut client = Client::new(ClientLimits::new(1, 1, 1, 1, 1).unwrap());
|
||||||
|
let session = client
|
||||||
|
.open_session("world", SecurityContext::new("alice"))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.open_session("other", SecurityContext::new("alice"))
|
||||||
|
.unwrap_err()
|
||||||
|
.kind,
|
||||||
|
ClientErrorKind::Capacity
|
||||||
|
);
|
||||||
|
let first = client.refresh_object(&session, "/scope/item").unwrap();
|
||||||
|
assert!(
|
||||||
|
client
|
||||||
|
.ensure_observed(&session, "/scope/other", 0)
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.refresh_object(&session, "/scope/other")
|
||||||
|
.unwrap_err()
|
||||||
|
.kind,
|
||||||
|
ClientErrorKind::Capacity
|
||||||
|
);
|
||||||
|
client
|
||||||
|
.complete(first.id, Response::Observe(Ok(object("item", None))))
|
||||||
|
.unwrap();
|
||||||
|
observe(&mut client, &session, "/scope/other", object("other", None));
|
||||||
|
assert_eq!(client.known_space(&session).unwrap().len(), 1);
|
||||||
|
for _ in 0..3 {
|
||||||
|
let request = client.refresh_object(&session, "/scope/other").unwrap();
|
||||||
|
client
|
||||||
|
.complete(request.id, Response::Observe(Ok(object("wrong", None))))
|
||||||
|
.unwrap_err();
|
||||||
|
}
|
||||||
|
assert_eq!(client.diagnostics(&session).unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_direct_requests_do_not_acquire_pending_state() {
|
||||||
|
let (mut client, session) = setup();
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.refresh_object(&session, "not/absolute")
|
||||||
|
.unwrap_err()
|
||||||
|
.kind,
|
||||||
|
ClientErrorKind::InvalidRequest
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.prepare_interface(
|
||||||
|
&session,
|
||||||
|
InterfaceReference {
|
||||||
|
scope: "/".into(),
|
||||||
|
name: "".into()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.kind,
|
||||||
|
ClientErrorKind::InvalidRequest
|
||||||
|
);
|
||||||
|
assert_eq!(client.in_flight_len(), 0);
|
||||||
|
assert!(client.known_space(&session).unwrap().is_empty());
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
#![cfg(feature = "http")]
|
||||||
|
//! The same stateful scenario runs through direct DTOs and the HTTP facade.
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use wip_client::direct::{Client as DirectClient, Request, Response};
|
||||||
|
use wip_client::{
|
||||||
|
CallOutcome, Client, ClientLimits, Completion, InterfaceObservation, ObjectObservation,
|
||||||
|
RequestId, SecurityContext, SessionId,
|
||||||
|
};
|
||||||
|
use wip_http::{
|
||||||
|
Limits, decode_call_operation_request, decode_fetch_interface_request, decode_observe_request,
|
||||||
|
encode_call_operation_response, encode_fetch_interface_response, encode_observe_response,
|
||||||
|
encode_protocol_error_response,
|
||||||
|
};
|
||||||
|
use wip_protocol::{
|
||||||
|
CallOperationResponse, FetchInterfaceResponse, INTERFACE_FORMAT_V1, InterfaceDescriptor,
|
||||||
|
InterfaceReference, Object, ObjectObservation as ProtocolObservation, OperationDeclaration,
|
||||||
|
ProtocolError, ProtocolErrorCode, ProtocolInteraction, ReturnDeclaration, TypeExpr, Value,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn limits() -> Limits {
|
||||||
|
Limits::new(64 * 1024, 64 * 1024, 64).unwrap()
|
||||||
|
}
|
||||||
|
fn reference() -> InterfaceReference {
|
||||||
|
InterfaceReference {
|
||||||
|
scope: "/scope".into(),
|
||||||
|
name: "math".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn descriptor() -> InterfaceDescriptor {
|
||||||
|
InterfaceDescriptor {
|
||||||
|
format: INTERFACE_FORMAT_V1.into(),
|
||||||
|
documentation: None,
|
||||||
|
types: vec![],
|
||||||
|
operations: vec![OperationDeclaration {
|
||||||
|
name: "run".into(),
|
||||||
|
documentation: None,
|
||||||
|
parameters: vec![],
|
||||||
|
returns: ReturnDeclaration {
|
||||||
|
documentation: None,
|
||||||
|
r#type: TypeExpr::Integer,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn observation(name: &str, identity: &str) -> ProtocolObservation {
|
||||||
|
ProtocolObservation {
|
||||||
|
object: Object {
|
||||||
|
name: name.into(),
|
||||||
|
description: None,
|
||||||
|
interfaces: vec![reference()],
|
||||||
|
r#ref: Some(identity.into()),
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
children: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn interface(identity: &str) -> FetchInterfaceResponse {
|
||||||
|
FetchInterfaceResponse {
|
||||||
|
interface: reference(),
|
||||||
|
scope_ref: Some(identity.into()),
|
||||||
|
validator: None,
|
||||||
|
descriptor: descriptor(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Channel {
|
||||||
|
Direct(DirectClient),
|
||||||
|
Http(Client),
|
||||||
|
}
|
||||||
|
impl Channel {
|
||||||
|
fn core(&self) -> &DirectClient {
|
||||||
|
match self {
|
||||||
|
Self::Direct(client) => client,
|
||||||
|
Self::Http(client) => client,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn observe(&mut self, session: &SessionId, path: &str) -> (RequestId, Request) {
|
||||||
|
match self {
|
||||||
|
Self::Direct(client) => {
|
||||||
|
let p = client.refresh_object(session, path).unwrap();
|
||||||
|
(p.id, p.request)
|
||||||
|
}
|
||||||
|
Self::Http(client) => {
|
||||||
|
let p = client.refresh_object(session, path).unwrap();
|
||||||
|
(
|
||||||
|
p.id,
|
||||||
|
Request::Observe(decode_observe_request(p.request.body(), limits()).unwrap()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn fetch(&mut self, session: &SessionId) -> (RequestId, Request) {
|
||||||
|
match self {
|
||||||
|
Self::Direct(client) => {
|
||||||
|
let p = client.prepare_interface(session, reference()).unwrap();
|
||||||
|
(p.id, p.request)
|
||||||
|
}
|
||||||
|
Self::Http(client) => {
|
||||||
|
let p = client.prepare_interface(session, reference()).unwrap();
|
||||||
|
(
|
||||||
|
p.id,
|
||||||
|
Request::FetchInterface(
|
||||||
|
decode_fetch_interface_request(p.request.body(), limits()).unwrap(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn call(&mut self, session: &SessionId) -> (RequestId, Request) {
|
||||||
|
match self {
|
||||||
|
Self::Direct(client) => {
|
||||||
|
let p = client
|
||||||
|
.prepare_call(session, "/scope/item", &reference(), "run", BTreeMap::new())
|
||||||
|
.unwrap();
|
||||||
|
client.mark_dispatched(p.id).unwrap();
|
||||||
|
(p.id, p.request)
|
||||||
|
}
|
||||||
|
Self::Http(client) => {
|
||||||
|
let p = client
|
||||||
|
.prepare_call(session, "/scope/item", &reference(), "run", BTreeMap::new())
|
||||||
|
.unwrap();
|
||||||
|
client.mark_dispatched(p.id).unwrap();
|
||||||
|
(
|
||||||
|
p.id,
|
||||||
|
Request::CallOperation(
|
||||||
|
decode_call_operation_request(p.request.body(), &descriptor(), limits())
|
||||||
|
.unwrap(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn complete(&mut self, pending: (RequestId, Request), result: Response) -> Completion {
|
||||||
|
let (id, request) = pending;
|
||||||
|
match self {
|
||||||
|
Self::Direct(client) => client.complete(id, result).unwrap(),
|
||||||
|
Self::Http(client) => {
|
||||||
|
let wire = match (request, result) {
|
||||||
|
(Request::Observe(request), Response::Observe(Ok(value))) => {
|
||||||
|
encode_observe_response(&request, &value, limits())
|
||||||
|
}
|
||||||
|
(Request::FetchInterface(request), Response::FetchInterface(Ok(value))) => {
|
||||||
|
encode_fetch_interface_response(&request, &value, limits())
|
||||||
|
}
|
||||||
|
(Request::CallOperation(request), Response::CallOperation(Ok(value))) => {
|
||||||
|
encode_call_operation_response(&request, &descriptor(), &value, limits())
|
||||||
|
}
|
||||||
|
(_, Response::Observe(Err(error))) => encode_protocol_error_response(
|
||||||
|
ProtocolInteraction::Observe,
|
||||||
|
&error,
|
||||||
|
limits(),
|
||||||
|
),
|
||||||
|
(_, Response::FetchInterface(Err(error))) => encode_protocol_error_response(
|
||||||
|
ProtocolInteraction::FetchInterface,
|
||||||
|
&error,
|
||||||
|
limits(),
|
||||||
|
),
|
||||||
|
(_, Response::CallOperation(Err(error))) => encode_protocol_error_response(
|
||||||
|
ProtocolInteraction::CallOperation,
|
||||||
|
&error,
|
||||||
|
limits(),
|
||||||
|
),
|
||||||
|
_ => panic!("test interaction mismatch"),
|
||||||
|
}
|
||||||
|
.unwrap();
|
||||||
|
client.complete(id, wire).unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
struct Snapshot {
|
||||||
|
objects: Vec<ObjectObservation>,
|
||||||
|
interface: Option<InterfaceObservation>,
|
||||||
|
outcomes: Vec<CallOutcome>,
|
||||||
|
requests: Vec<Request>,
|
||||||
|
}
|
||||||
|
fn scenario(http: bool) -> Snapshot {
|
||||||
|
let state_limits = ClientLimits::new(4, 16, 8, 8, 8).unwrap();
|
||||||
|
let (mut channel, session) = if http {
|
||||||
|
let mut client = Client::new(state_limits, limits());
|
||||||
|
let session = client
|
||||||
|
.open_session("https://example.test/wip", SecurityContext::new("alice"))
|
||||||
|
.unwrap();
|
||||||
|
(Channel::Http(client), session)
|
||||||
|
} else {
|
||||||
|
let mut client = DirectClient::new(state_limits);
|
||||||
|
let session = client
|
||||||
|
.open_session("local world", SecurityContext::new("alice"))
|
||||||
|
.unwrap();
|
||||||
|
(Channel::Direct(client), session)
|
||||||
|
};
|
||||||
|
let mut requests = vec![];
|
||||||
|
for (path, name, identity) in [
|
||||||
|
("/scope", "scope", "scope-v1"),
|
||||||
|
("/scope/item", "item", "item-v1"),
|
||||||
|
] {
|
||||||
|
let p = channel.observe(&session, path);
|
||||||
|
requests.push(p.1.clone());
|
||||||
|
channel.complete(p, Response::Observe(Ok(observation(name, identity))));
|
||||||
|
}
|
||||||
|
let p = channel.fetch(&session);
|
||||||
|
requests.push(p.1.clone());
|
||||||
|
channel.complete(p, Response::FetchInterface(Ok(interface("scope-v1"))));
|
||||||
|
let p = channel.call(&session);
|
||||||
|
requests.push(p.1.clone());
|
||||||
|
channel.complete(
|
||||||
|
p,
|
||||||
|
Response::CallOperation(Ok(CallOperationResponse {
|
||||||
|
result: Value::Integer(42),
|
||||||
|
validator: Some(vec![2]),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
let p = channel.call(&session);
|
||||||
|
requests.push(p.1.clone());
|
||||||
|
channel.complete(
|
||||||
|
p,
|
||||||
|
Response::CallOperation(Err(ProtocolError {
|
||||||
|
code: ProtocolErrorCode::InterfaceMismatch,
|
||||||
|
message: "changed".into(),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
let p = channel.observe(&session, "/scope/item");
|
||||||
|
channel.complete(p, Response::Observe(Ok(observation("item", "item-v1"))));
|
||||||
|
let p = channel.fetch(&session);
|
||||||
|
channel.complete(p, Response::FetchInterface(Ok(interface("scope-v1"))));
|
||||||
|
let old = channel.fetch(&session);
|
||||||
|
let p = channel.observe(&session, "/scope");
|
||||||
|
channel.complete(p, Response::Observe(Ok(observation("scope", "scope-v2"))));
|
||||||
|
let old_id = old.0;
|
||||||
|
assert_eq!(
|
||||||
|
channel.complete(old, Response::FetchInterface(Ok(interface("scope-v1")))),
|
||||||
|
Completion::StaleResponseRejected(old_id)
|
||||||
|
);
|
||||||
|
let p = channel.fetch(&session);
|
||||||
|
channel.complete(p, Response::FetchInterface(Ok(interface("scope-v2"))));
|
||||||
|
let p = channel.call(&session);
|
||||||
|
requests.push(p.1.clone());
|
||||||
|
channel.complete(
|
||||||
|
p,
|
||||||
|
Response::CallOperation(Err(ProtocolError {
|
||||||
|
code: ProtocolErrorCode::OperationOutcomeUnknown,
|
||||||
|
message: "unknown".into(),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
assert_eq!(channel.core().in_flight_len(), 0);
|
||||||
|
Snapshot {
|
||||||
|
objects: channel.core().known_space(&session).unwrap(),
|
||||||
|
interface: channel.core().interface(&session, &reference()).cloned(),
|
||||||
|
outcomes: channel
|
||||||
|
.core()
|
||||||
|
.call_history(&session)
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|record| record.outcome.clone())
|
||||||
|
.collect(),
|
||||||
|
requests,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn direct_and_http_agree_on_requests_state_calls_scope_invalidation_and_uncertainty() {
|
||||||
|
assert_eq!(scenario(false), scenario(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn http_endpoint_and_identical_direct_identity_do_not_share_cache() {
|
||||||
|
let mut client = Client::new(ClientLimits::new(4, 16, 8, 8, 8).unwrap(), limits());
|
||||||
|
let http = client
|
||||||
|
.open_session("https://example.test/wip", SecurityContext::new("alice"))
|
||||||
|
.unwrap();
|
||||||
|
let direct = DirectClient::open_session(
|
||||||
|
&mut client,
|
||||||
|
"https://example.test/wip/",
|
||||||
|
SecurityContext::new("alice"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(http, direct);
|
||||||
|
let request = DirectClient::refresh_object(&mut client, &direct, "/scope").unwrap();
|
||||||
|
DirectClient::complete(
|
||||||
|
&mut client,
|
||||||
|
request.id,
|
||||||
|
Response::Observe(Ok(observation("scope", "local"))),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(client.object(&http, "/scope").is_none());
|
||||||
|
assert!(client.refresh_object(&direct, "/scope").is_err());
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
#![cfg(feature = "http")]
|
||||||
|
//! Wire budgets are adapter-only and failed encoding must not acquire state.
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use wip_client::{Client, ClientErrorKind, ClientLimits, ObservationState, SecurityContext};
|
||||||
|
use wip_http::{Limits, encode_fetch_interface_response, encode_observe_response};
|
||||||
|
use wip_protocol::{
|
||||||
|
FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1, InterfaceDescriptor,
|
||||||
|
InterfaceReference, Object, ObjectObservation, ObserveRequest, OperationDeclaration,
|
||||||
|
ReturnDeclaration, TypeExpr,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn state_limits() -> ClientLimits {
|
||||||
|
ClientLimits::new(2, 8, 8, 8, 8).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_encoding_failure_leaves_retrieval_cache_and_inflight_unchanged() {
|
||||||
|
let mut client = Client::new(state_limits(), Limits::new(1, 4096, 32).unwrap());
|
||||||
|
let session = client
|
||||||
|
.open_session("https://example.test/wip", SecurityContext::new("alice"))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
client.refresh_object(&session, "/item").unwrap_err().kind,
|
||||||
|
ClientErrorKind::InvalidRequest
|
||||||
|
);
|
||||||
|
let reference = InterfaceReference {
|
||||||
|
scope: "/".into(),
|
||||||
|
name: "test".into(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.prepare_interface(&session, reference.clone())
|
||||||
|
.unwrap_err()
|
||||||
|
.kind,
|
||||||
|
ClientErrorKind::InvalidRequest
|
||||||
|
);
|
||||||
|
assert!(client.object(&session, "/item").is_none());
|
||||||
|
assert!(client.interface(&session, &reference).is_none());
|
||||||
|
assert_eq!(client.in_flight_len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn call_encoding_failure_keeps_fresh_observations_and_no_pending_call() {
|
||||||
|
let limits = Limits::new(80, 4096, 32).unwrap();
|
||||||
|
let mut client = Client::new(state_limits(), limits);
|
||||||
|
let session = client
|
||||||
|
.open_session("https://example.test/wip", SecurityContext::new("alice"))
|
||||||
|
.unwrap();
|
||||||
|
let reference = InterfaceReference {
|
||||||
|
scope: "/".into(),
|
||||||
|
name: "test".into(),
|
||||||
|
};
|
||||||
|
let observe = client.refresh_object(&session, "/item").unwrap();
|
||||||
|
client
|
||||||
|
.complete(
|
||||||
|
observe.id,
|
||||||
|
encode_observe_response(
|
||||||
|
&ObserveRequest {
|
||||||
|
path: "/item".into(),
|
||||||
|
depth: 0,
|
||||||
|
},
|
||||||
|
&ObjectObservation {
|
||||||
|
object: Object {
|
||||||
|
name: "item".into(),
|
||||||
|
description: None,
|
||||||
|
interfaces: vec![reference.clone()],
|
||||||
|
r#ref: None,
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
children: None,
|
||||||
|
},
|
||||||
|
limits,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let fetch = client
|
||||||
|
.prepare_interface(&session, reference.clone())
|
||||||
|
.unwrap();
|
||||||
|
client
|
||||||
|
.complete(
|
||||||
|
fetch.id,
|
||||||
|
encode_fetch_interface_response(
|
||||||
|
&FetchInterfaceRequest {
|
||||||
|
interface: reference.clone(),
|
||||||
|
},
|
||||||
|
&FetchInterfaceResponse {
|
||||||
|
interface: reference.clone(),
|
||||||
|
scope_ref: None,
|
||||||
|
validator: None,
|
||||||
|
descriptor: InterfaceDescriptor {
|
||||||
|
format: INTERFACE_FORMAT_V1.into(),
|
||||||
|
documentation: None,
|
||||||
|
types: vec![],
|
||||||
|
operations: vec![OperationDeclaration {
|
||||||
|
name: "run".into(),
|
||||||
|
documentation: None,
|
||||||
|
parameters: vec![],
|
||||||
|
returns: ReturnDeclaration {
|
||||||
|
documentation: None,
|
||||||
|
r#type: TypeExpr::Integer,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
limits,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.prepare_call(&session, "/item", &reference, "run", BTreeMap::new())
|
||||||
|
.unwrap_err()
|
||||||
|
.kind,
|
||||||
|
ClientErrorKind::InvalidRequest
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client.object(&session, "/item").unwrap().state,
|
||||||
|
ObservationState::Fresh
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client.interface(&session, &reference).unwrap().state,
|
||||||
|
ObservationState::Fresh
|
||||||
|
);
|
||||||
|
assert!(client.call_history(&session).unwrap().is_empty());
|
||||||
|
assert_eq!(client.in_flight_len(), 0);
|
||||||
|
}
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
# wip-host
|
# wip-host
|
||||||
|
|
||||||
Framework-independent Host publication and operation dispatch for the Web Interface Protocol (WIP).
|
Framework-independent Host publication and operation dispatch for the Worldspace Interface Protocol (WIP).
|
||||||
|
|
||||||
`wip-host` registers generated interfaces, explicitly publishes them on request-scoped objects, validates object and interface preconditions, and performs interface-qualified operation dispatch against one host-owned instance. Backend transactions, persistence, and network serving remain application responsibilities.
|
`wip-host` registers generated interfaces, explicitly publishes them on request-scoped objects, validates object and interface preconditions, and performs interface-qualified operation dispatch against one host-owned instance. Backend transactions, persistence, and network serving remain application responsibilities.
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
wip-host = "0.1.0"
|
wip-host = "0.2.0"
|
||||||
wip-interface = "0.1.0"
|
wip-interface = "0.2.0"
|
||||||
```
|
```
|
||||||
|
|
||||||
Protocol publication and dispatch use `wip_protocol::InterfaceReference { scope, name }` and compare both fields exactly. The legacy `InterfaceRef` registration token remains Host-private: `protocol_reference()` maps its opaque string verbatim to the name at root scope `/`, without parsing it. Registry lookup and fetch accept structured references; non-root references do not resolve legacy registrations. Legacy responses omit `scope_ref`.
|
Protocol publication and dispatch use `wip_protocol::InterfaceReference { scope, name }` and compare both fields exactly. Registration takes an explicit `Scope` and local name; `InterfaceRef::protocol_reference()` preserves those coordinates without parsing display text. Providers attach a current `ScopeObservation` inside the coherent backend boundary, and fetch returns its optional Object ref as `scope_ref`. Calls check a supplied `scope_ref` against that current binding; omission adds no identity precondition. Retire a Scope on Object deletion, replacement or movement; retained registry artifacts alone do not establish publication.
|
||||||
|
|
||||||
Malformed interface-reference shapes fail calls as `InvalidRequest` before object resolution. A well-shaped reference outside the target path's ancestor-or-self scope fails at the membership check as `InterfaceMismatch`.
|
Malformed interface-reference shapes fail calls as `InvalidRequest` before object resolution. A well-shaped reference outside the target path's ancestor-or-self scope fails at the membership check as `InterfaceMismatch`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Host-side publication and operation dispatch for Web Interface Protocol objects.
|
//! Host-side publication and operation dispatch for Worldspace Interface Protocol objects.
|
||||||
//!
|
//!
|
||||||
//! # Responsibility boundaries
|
//! # Responsibility boundaries
|
||||||
//!
|
//!
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ The crate maps the three WIP Core interactions to fixed HTTP routes and strict U
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
wip-http = "0.1.0"
|
wip-http = "0.2.0"
|
||||||
```
|
```
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# wip-interface
|
# wip-interface
|
||||||
|
|
||||||
Public Rust authoring API for Web Interface Protocol (WIP) interfaces and application DTO codecs.
|
Public Rust authoring API for Worldspace Interface Protocol (WIP) interfaces and application DTO codecs.
|
||||||
|
|
||||||
The crate re-exports `#[interface]` and `#[derive(WipType)]`, generates Interface Descriptors and typed dispatch companions, and converts application-owned Rust values to and from the transport-independent value model.
|
The crate re-exports `#[interface]` and `#[derive(WipType)]`, generates Interface Descriptors and typed dispatch companions, and converts application-owned Rust values to and from the transport-independent value model.
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
wip-interface = "0.1.0"
|
wip-interface = "0.2.0"
|
||||||
```
|
```
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Public authoring API for Web Interface Protocol (WIP) interfaces.
|
//! Public authoring API for Worldspace Interface Protocol (WIP) interfaces.
|
||||||
//!
|
//!
|
||||||
//! Add `wip-interface` to an application and import it under the short name
|
//! Add `wip-interface` to an application and import it under the short name
|
||||||
//! used by the attribute examples:
|
//! used by the attribute examples:
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# wip-protocol
|
# wip-protocol
|
||||||
|
|
||||||
Transport-independent logical types and validation for the Web Interface Protocol (WIP).
|
Transport-independent logical types and validation for the Worldspace Interface Protocol (WIP).
|
||||||
|
|
||||||
This crate defines the canonical Object and Interface Descriptor models, the three Core interaction request and response types, schema-neutral operation `Value`s, validators, protocol errors, and structural validation. It does not define an HTTP mapping, application DTOs, persistence, or network I/O.
|
This crate defines the canonical Object and Interface Descriptor models, the three Core interaction request and response types, schema-neutral operation `Value`s, validators, protocol errors, and structural validation. It does not define an HTTP mapping, application DTOs, persistence, or network I/O.
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
wip-protocol = "0.1.0"
|
wip-protocol = "0.2.0"
|
||||||
```
|
```
|
||||||
|
|
||||||
Interface identity is an exact `InterfaceReference { scope, name }` pair.
|
Interface identity is an exact `InterfaceReference { scope, name }` pair.
|
||||||
@@ -16,7 +16,7 @@ and Object/interaction validation at response boundaries. Fetching needs no
|
|||||||
target Object path. Fetch responses and interface call targets carry optional
|
target Object path. Fetch responses and interface call targets carry optional
|
||||||
opaque string `scope_ref` metadata, not a third identity field. Descriptors do
|
opaque string `scope_ref` metadata, not a third identity field. Descriptors do
|
||||||
not embed identity. See the repository's [migration guide](../../MIGRATION.md)
|
not embed identity. See the repository's [migration guide](../../MIGRATION.md)
|
||||||
for the breaking Rust and HTTP wire changes and deferred consumer semantics.
|
for the breaking Rust and HTTP wire changes and consumer migration guidance.
|
||||||
|
|
||||||
See the [API documentation](https://docs.rs/wip-protocol) for the complete model and validation rules.
|
See the [API documentation](https://docs.rs/wip-protocol) for the complete model and validation rules.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Canonical transport-independent logical types for the Web Interface Protocol.
|
//! Canonical transport-independent logical types for the Worldspace Interface Protocol.
|
||||||
//!
|
//!
|
||||||
//! This crate follows the Core Model and `3-protocol.md` without assigning HTTP
|
//! This crate follows the Core Model and `3-protocol.md` without assigning HTTP
|
||||||
//! routes, methods, headers, URL encodings, or JSON envelopes. The correspondence
|
//! routes, methods, headers, URL encodings, or JSON envelopes. The correspondence
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "wip-text-view"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
description = "Standalone Object and Interface Text View signatures for WIP"
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
readme = "README.md"
|
||||||
|
documentation = "https://docs.rs/wip-text-view"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
wip-protocol.workspace = true
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
# wip-text-view
|
||||||
|
|
||||||
|
Standalone Object and Interface signature rendering for WIP Text View, following
|
||||||
|
`4.2-text-view.md` at reference revision `6086f3c` and the Protocol semantics in
|
||||||
|
`3-protocol.md`. Only `wip-protocol` and the Rust standard library are dependencies.
|
||||||
|
No Client, Host, HTTP I/O, cache, UI framework or retrieval service is required.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
wip-text-view = "0.2.0"
|
||||||
|
wip-protocol = "0.2.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Single-entity API
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wip_protocol::{Object, InterfaceReference, InterfaceDescriptor, INTERFACE_FORMAT_V1};
|
||||||
|
use wip_text_view::{Interface, render_object, render_interface};
|
||||||
|
|
||||||
|
let reference = InterfaceReference { scope: "/".into(), name: "example".into() };
|
||||||
|
let object = Object {
|
||||||
|
name: "item".into(), description: None, interfaces: vec![reference.clone()],
|
||||||
|
r#ref: None, validator: None,
|
||||||
|
};
|
||||||
|
let object_signature = render_object(&object)?;
|
||||||
|
assert_eq!(object_signature,
|
||||||
|
"object {\n name: \"item\";\n interfaces: [\"/::example\"];\n}\n");
|
||||||
|
|
||||||
|
let descriptor = InterfaceDescriptor {
|
||||||
|
format: INTERFACE_FORMAT_V1.into(), documentation: None,
|
||||||
|
types: vec![], operations: vec![],
|
||||||
|
};
|
||||||
|
let interface = Interface { reference: &reference, descriptor: &descriptor };
|
||||||
|
let interface_signature = render_interface(&interface)?;
|
||||||
|
assert_eq!(interface_signature, "interface \"/::example\" {\n}\n");
|
||||||
|
# Ok::<(), wip_text_view::RenderError>(())
|
||||||
|
```
|
||||||
|
|
||||||
|
`Interface<'a>` is one borrowed SDK input entity containing a complete structured
|
||||||
|
reference and its corresponding descriptor, not a wire schema extension. The
|
||||||
|
caller pairs them from a coherent observation or publication. The descriptor has
|
||||||
|
no identity, so a renderer cannot prove the pairing's provenance or freshness.
|
||||||
|
For example, a caller with a validated `FetchInterfaceResponse` can borrow its
|
||||||
|
`interface` and `descriptor`; it does not pass `scope_ref` or validators.
|
||||||
|
|
||||||
|
An Object has no placement path. This API always emits `object { ... }`, never
|
||||||
|
requests a path and never infers one from name or ref. It requires no descriptor.
|
||||||
|
An Interface needs no Object, separate name/scope/context arguments or lookup.
|
||||||
|
There is no document aggregator, configuration or details selector.
|
||||||
|
|
||||||
|
## Typed display tokens
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wip_protocol::{Object, InterfaceReference, InterfaceDescriptor, INTERFACE_FORMAT_V1};
|
||||||
|
use wip_text_view::{
|
||||||
|
Interface, TokenKind, tokenize_object, tokenize_interface, to_plain_text,
|
||||||
|
render_object, render_interface,
|
||||||
|
};
|
||||||
|
|
||||||
|
let reference = InterfaceReference { scope: "/".into(), name: "example".into() };
|
||||||
|
let object = Object {
|
||||||
|
name: "item".into(), description: None, interfaces: vec![reference.clone()],
|
||||||
|
r#ref: None, validator: None,
|
||||||
|
};
|
||||||
|
let object_tokens = tokenize_object(&object)?;
|
||||||
|
assert_eq!(object_tokens[0].kind(), TokenKind::Keyword);
|
||||||
|
assert_eq!(object_tokens[0].text(), "object");
|
||||||
|
assert_eq!(to_plain_text(&object_tokens), render_object(&object)?);
|
||||||
|
|
||||||
|
let descriptor = InterfaceDescriptor {
|
||||||
|
format: INTERFACE_FORMAT_V1.into(), documentation: None,
|
||||||
|
types: vec![], operations: vec![],
|
||||||
|
};
|
||||||
|
let input = Interface { reference: &reference, descriptor: &descriptor };
|
||||||
|
let tokens = tokenize_interface(&input)?;
|
||||||
|
for token in &tokens {
|
||||||
|
// Custom renderers can select presentation by role without reparsing text.
|
||||||
|
if token.kind() == TokenKind::Reference {
|
||||||
|
assert_eq!(token.text(), "\"/::example\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let joined: String = tokens.iter().map(|token| token.text()).collect();
|
||||||
|
assert_eq!(to_plain_text(&tokens), joined);
|
||||||
|
assert_eq!(to_plain_text(&tokens), render_interface(&input)?);
|
||||||
|
# Ok::<(), wip_text_view::RenderError>(())
|
||||||
|
```
|
||||||
|
|
||||||
|
`tokenize_object` / `tokenize_interface` return `Result<Vec<Token>, RenderError>`.
|
||||||
|
They generate **directly from the input structure**, not by lexing a completed
|
||||||
|
Text View string. Each token owns its text (and can outlive the input) and exposes
|
||||||
|
`kind()` / `text()`. Existing render APIs use the same token generation followed
|
||||||
|
by `to_plain_text(&[Token])`; there is no second grammar/escape/layout engine.
|
||||||
|
|
||||||
|
### Kind and splitting contract
|
||||||
|
|
||||||
|
| Kind | One token contains |
|
||||||
|
| --- | --- |
|
||||||
|
| `Keyword` | One grammar keyword, Object property label, or primitive type keyword |
|
||||||
|
| `TypeName` | One type declaration name or Named type reference |
|
||||||
|
| `OperationName` | One Operation declaration name |
|
||||||
|
| `ParameterName` / `FieldName` | One parameter / record field name |
|
||||||
|
| `CaseName` | One enum or union case name |
|
||||||
|
| `String` | One complete quoted Object name or description |
|
||||||
|
| `Reference` | One complete quoted absolute Interface reference, with both quoting layers |
|
||||||
|
| `Documentation` | Entire `/// "summary"` content, including marker and internal space, excluding indentation/LF |
|
||||||
|
| `Symbol` | One punctuation mark, except the indivisible two-character `->` arrow |
|
||||||
|
| `Whitespace` | One inter-token ASCII space |
|
||||||
|
| `Newline` | One LF, including the final LF |
|
||||||
|
| `Indentation` | All leading spaces of a non-root line, two spaces per depth level |
|
||||||
|
|
||||||
|
Quoted identifiers retain their semantic role even when named like keywords.
|
||||||
|
Quotes, backticks and all WIP escapes are part of the data token, never separate
|
||||||
|
symbols. Host spaces inside quoted data remain in that token. Adjacent symbols
|
||||||
|
are separate; no empty tokens or zero-depth indentation tokens are emitted.
|
||||||
|
`TokenKind` is non-exhaustive so consumers should handle future roles with a
|
||||||
|
fallback. The API does not distinguish type declaration names from Named uses,
|
||||||
|
or enum cases from union cases; both share the documented role.
|
||||||
|
|
||||||
|
All layout is included. Concatenate token text **without separators**, not with
|
||||||
|
`join(" ")`. `to_plain_text` is only that concatenation: no interpretation,
|
||||||
|
validation, quoting, escaping or layout completion. An empty slice yields `""`;
|
||||||
|
a filtered/reordered slice is allowed but need not be a complete signature.
|
||||||
|
|
||||||
|
Token text is escaped for **WIP Text View**, not for HTML or other destinations.
|
||||||
|
Custom renderers must escape for their destination and treat Host text as data,
|
||||||
|
not markup or instructions. This crate supplies no HTML, ANSI, color or theme
|
||||||
|
renderer/adapter and no external text lexer/parser or AST.
|
||||||
|
|
||||||
|
## Output and validation
|
||||||
|
|
||||||
|
- UTF-8 plain text with LF, two-space indentation and a final LF, without ANSI,
|
||||||
|
Markdown fences, syntax highlighting or provider envelopes. Nonempty composites
|
||||||
|
and parameter lists use multiple lines; layout is deterministic here, but not a
|
||||||
|
byte-canonical protocol format.
|
||||||
|
- All Object references remain in Host order. All types, then all operations,
|
||||||
|
fields, parameters and cases remain in declaration order, without elision.
|
||||||
|
- Missing vs empty description/documentation, optional vs unit, no payload vs
|
||||||
|
unit payload, and empty record/enum/union/declaration sets remain distinct.
|
||||||
|
- Full absolute references use component quoting **and** outer JSON-string quoting.
|
||||||
|
Identifiers use keywords/backtick rules. All Host strings use standard JSON
|
||||||
|
escapes plus DEL/C1, Unicode line/paragraph separator and bidi-control escapes.
|
||||||
|
No normalization, case conversion, URL encoding or input mutation occurs.
|
||||||
|
- Documentation summaries are displayed in their prescribed positions, including
|
||||||
|
return documentation between `)` and `->`. Details are not displayed. Host text
|
||||||
|
is data, not renderer instructions or markup; quoting alone is not a prompt
|
||||||
|
injection defense. AI adapters must preserve the trust boundary.
|
||||||
|
- Object ref/validator, children, domain data and Interface scope_ref/validator
|
||||||
|
are not signature content. No annotations are inferred from operation names.
|
||||||
|
|
||||||
|
The render functions return `Result<String, RenderError>`; token generation
|
||||||
|
returns `Result<Vec<Token>, RenderError>`. Neither returns partial signatures. Resource checks happen first, followed by existing Protocol validators:
|
||||||
|
Object shape and reference uniqueness, or Interface reference shape plus descriptor
|
||||||
|
structure, duplicate names, unknown Named references and cycles. Descriptor format
|
||||||
|
is checked after semantics: valid but unsupported formats return
|
||||||
|
`UnsupportedDescriptorFormat`, distinct from `InvalidDescriptor`. Supported format
|
||||||
|
is exactly `wip-interface/1`. Other validation errors preserve the Protocol's typed
|
||||||
|
`ValidationError` (also exposed as the error source).
|
||||||
|
|
||||||
|
Bounded input limits are **1 MiB** of total string/opaque byte data, **16,384**
|
||||||
|
declarations/fields/parameters/cases/type expressions/references, **256** named type
|
||||||
|
declarations, and **64** TypeExpr nesting levels. Details and Object runtime
|
||||||
|
metadata count toward input bytes even though they are not rendered. A limit
|
||||||
|
returns `RenderError::ResourceLimit`, never truncates or supplies `...`. Counts
|
||||||
|
bound allocation and recursive validation before those validators run; these are
|
||||||
|
library safety limits, not Protocol restrictions.
|
||||||
|
|
||||||
|
Without path/context, this library cannot attest Object scope ancestry, placement,
|
||||||
|
observation consistency, identity, membership, authorization or currentness.
|
||||||
|
Callers validate those at their observation boundary. Callers also own retrieval,
|
||||||
|
resolution, cache, selection, multiple-signature arrangement and AI/UI adaptation.
|
||||||
|
Text is display-only, not serialization, a digest, identity/cache key or call
|
||||||
|
target. No parser or round-trip API is supplied.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
`tests/signatures.rs` is an external consumer of the public APIs. Goldens cover
|
||||||
|
the spec's Object, issue.read, CloseResult, search/return documentation and
|
||||||
|
identifier examples (with permitted multiline layout), plus nested documentation.
|
||||||
|
The suite checks all TypeExpr variants/positions, ordering and empty distinctions,
|
||||||
|
keyword and Unicode names, both reference quoting layers, all required control
|
||||||
|
escapes, input immutability, metadata exclusion and typed rejection cases. Every
|
||||||
|
existing case compares the render result (including exact errors) with token
|
||||||
|
text concatenation and `to_plain_text`. Additional tests assert complete Object
|
||||||
|
token splitting, all semantic roles, keyword collisions, atomic escaped data,
|
||||||
|
all documentation positions, layout tokens and ownership beyond input lifetime.
|
||||||
|
Input budgets bound token counts per node, indentation depth and escape expansion;
|
||||||
|
token generation checks them before any rendering/validation traversal.
|
||||||
|
|
||||||
|
```console
|
||||||
|
cargo test -p wip-text-view
|
||||||
|
cargo clippy -p wip-text-view --all-targets -- -D warnings
|
||||||
|
RUSTDOCFLAGS='-D warnings' cargo doc -p wip-text-view --no-deps
|
||||||
|
```
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
//! Standalone, single-entity WIP Text View signatures.
|
||||||
|
//!
|
||||||
|
//! [`render_object`] accepts just an [`Object`], and [`render_interface`] accepts
|
||||||
|
//! just an [`Interface`] (a reference paired with its corresponding descriptor).
|
||||||
|
//! No Client, Host, HTTP, UI, retrieval, cache, path or display context is needed.
|
||||||
|
//! The caller supplies a coherent observation; descriptor identity cannot be
|
||||||
|
//! inferred from descriptor contents. No protocol wire fields are added.
|
||||||
|
//!
|
||||||
|
//! Output follows WIP `4.2-text-view.md` (reference revision `6086f3c`): UTF-8
|
||||||
|
//! plain text, LF, two-space indentation, all declarations in original order,
|
||||||
|
//! and documentation summaries only. There is no truncation or details setting.
|
||||||
|
//! Object paths are neither requested nor guessed: output uses `object { ... }`.
|
||||||
|
//! Runtime metadata is never rendered. Host documentation is untrusted data,
|
||||||
|
//! not markup or instructions; quoting does not by itself prevent prompt injection.
|
||||||
|
//!
|
||||||
|
//! Each call checks resource limits, then Protocol shape/semantic validation.
|
||||||
|
//! Interface rendering additionally requires [`wip_protocol::INTERFACE_FORMAT_V1`].
|
||||||
|
//! Limits reject the entire input, never produce partial text: at most 1 MiB of
|
||||||
|
//! input string/byte data, 16,384 declarations/fields/cases/type expressions or
|
||||||
|
//! references, 256 named type declarations (bounding validator graph recursion),
|
||||||
|
//! and 64 nested TypeExpr levels. These are implementation limits, not new
|
||||||
|
//! Protocol semantics. An Object without placement context can only be checked
|
||||||
|
//! for name/reference shape and duplicate references; the caller must validate
|
||||||
|
//! scope ancestry and observation consistency at its observation boundary.
|
||||||
|
//!
|
||||||
|
//! Signatures are display-only: no parser, round-trip, identity comparison,
|
||||||
|
//! cache key or call-target reconstruction is provided. The caller owns fetching,
|
||||||
|
//! freshness, authorization, selection and arrangement of multiple signatures.
|
||||||
|
//!
|
||||||
|
//! ```
|
||||||
|
//! use wip_protocol::{Object, InterfaceReference, InterfaceDescriptor, INTERFACE_FORMAT_V1};
|
||||||
|
//! use wip_text_view::{Interface, render_object, render_interface};
|
||||||
|
//! let reference = InterfaceReference { scope: "/".into(), name: "example".into() };
|
||||||
|
//! let object = Object {
|
||||||
|
//! name: "item".into(), description: None, interfaces: vec![reference.clone()],
|
||||||
|
//! r#ref: Some("not a path".into()), validator: Some(vec![1]),
|
||||||
|
//! };
|
||||||
|
//! assert_eq!(render_object(&object)?,
|
||||||
|
//! "object {\n name: \"item\";\n interfaces: [\"/::example\"];\n}\n");
|
||||||
|
//! let descriptor = InterfaceDescriptor {
|
||||||
|
//! format: INTERFACE_FORMAT_V1.into(), documentation: None,
|
||||||
|
//! types: vec![], operations: vec![],
|
||||||
|
//! };
|
||||||
|
//! let interface = Interface { reference: &reference, descriptor: &descriptor };
|
||||||
|
//! assert_eq!(render_interface(&interface)?, "interface \"/::example\" {\n}\n");
|
||||||
|
//! # Ok::<(), wip_text_view::RenderError>(())
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! # Typed display tokens
|
||||||
|
//!
|
||||||
|
//! [`tokenize_object`] and [`tokenize_interface`] traverse the input structure
|
||||||
|
//! directly and return owned [`Token`] sequences, including all layout. Existing
|
||||||
|
//! render APIs use this same generation path followed by [`to_plain_text`]. The
|
||||||
|
//! input limits above also bound token allocation: tokens per node, indentation
|
||||||
|
//! depth and escape expansion are bounded; no partial signature is returned.
|
||||||
|
//! Token text is WIP-escaped, **not HTML-escaped** or escaped for other destinations.
|
||||||
|
//! See [`Token`] for the semantic splitting contract; no renderer/theme is supplied.
|
||||||
|
//!
|
||||||
|
//! ```
|
||||||
|
//! use wip_protocol::{InterfaceReference, InterfaceDescriptor, INTERFACE_FORMAT_V1};
|
||||||
|
//! use wip_text_view::{Interface, TokenKind, tokenize_interface, to_plain_text, render_interface};
|
||||||
|
//! let reference = InterfaceReference { scope: "/".into(), name: "example".into() };
|
||||||
|
//! let descriptor = InterfaceDescriptor {
|
||||||
|
//! format: INTERFACE_FORMAT_V1.into(), documentation: None,
|
||||||
|
//! types: vec![], operations: vec![],
|
||||||
|
//! };
|
||||||
|
//! let input = Interface { reference: &reference, descriptor: &descriptor };
|
||||||
|
//! let tokens = tokenize_interface(&input)?;
|
||||||
|
//! assert_eq!(tokens[0].kind(), TokenKind::Keyword);
|
||||||
|
//! assert_eq!(tokens[0].text(), "interface");
|
||||||
|
//! let concatenated: String = tokens.iter().map(|token| token.text()).collect();
|
||||||
|
//! assert_eq!(to_plain_text(&tokens), concatenated);
|
||||||
|
//! assert_eq!(to_plain_text(&tokens), render_interface(&input)?);
|
||||||
|
//! # Ok::<(), wip_text_view::RenderError>(())
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
#![deny(missing_docs)]
|
||||||
|
|
||||||
|
mod limits;
|
||||||
|
mod render;
|
||||||
|
mod token;
|
||||||
|
|
||||||
|
pub use token::{Token, TokenKind, to_plain_text};
|
||||||
|
|
||||||
|
use std::{error::Error, fmt};
|
||||||
|
use wip_protocol::{
|
||||||
|
INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceReference, Object, ValidationError,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// One Interface input entity, borrowing its identity and corresponding data.
|
||||||
|
///
|
||||||
|
/// The caller pairs values from the same observation/publication. The descriptor
|
||||||
|
/// itself has no identity, so rendering cannot verify provenance or freshness.
|
||||||
|
/// This is an SDK display input, not a replacement wire schema or a document.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Interface<'a> {
|
||||||
|
/// Complete, absolute, Client-facing Interface reference.
|
||||||
|
pub reference: &'a InterfaceReference,
|
||||||
|
/// Descriptor corresponding to `reference`.
|
||||||
|
pub descriptor: &'a InterfaceDescriptor,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Failure to render a complete, validated signature. No partial text is returned.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum RenderError {
|
||||||
|
/// Object-independent Protocol validation failed.
|
||||||
|
InvalidObject(ValidationError),
|
||||||
|
/// The Interface's structured reference is malformed.
|
||||||
|
InvalidReference(ValidationError),
|
||||||
|
/// Descriptor structure or semantics failed Protocol validation.
|
||||||
|
InvalidDescriptor(ValidationError),
|
||||||
|
/// A semantically valid descriptor uses a format this renderer cannot interpret.
|
||||||
|
UnsupportedDescriptorFormat {
|
||||||
|
/// Exact unsupported format identifier.
|
||||||
|
format: String,
|
||||||
|
},
|
||||||
|
/// Input exceeds an implementation safety limit, without truncation.
|
||||||
|
ResourceLimit {
|
||||||
|
/// Limit that was exceeded.
|
||||||
|
limit: ResourceLimit,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded input dimensions checked before recursive Protocol validation.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ResourceLimit {
|
||||||
|
/// Total input string and opaque byte length exceeds 1 MiB.
|
||||||
|
InputBytes,
|
||||||
|
/// Total declarations, fields, parameters, cases, expressions or references exceeds 16,384.
|
||||||
|
Nodes,
|
||||||
|
/// More than 256 named type declarations.
|
||||||
|
TypeDeclarations,
|
||||||
|
/// More than 64 nested TypeExpr levels (outer expression is level one).
|
||||||
|
TypeDepth,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for RenderError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::InvalidObject(e) => write!(f, "invalid Object: {e}"),
|
||||||
|
Self::InvalidReference(e) => write!(f, "invalid Interface reference: {e}"),
|
||||||
|
Self::InvalidDescriptor(e) => write!(f, "invalid Interface descriptor: {e}"),
|
||||||
|
Self::UnsupportedDescriptorFormat { format } => {
|
||||||
|
write!(f, "unsupported descriptor format: {format:?}")
|
||||||
|
}
|
||||||
|
Self::ResourceLimit { limit } => write!(f, "Text View input exceeds {limit:?} limit"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for RenderError {
|
||||||
|
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
Self::InvalidObject(e) | Self::InvalidReference(e) | Self::InvalidDescriptor(e) => {
|
||||||
|
Some(e)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders one complete Object signature, with no placement path or inferred data.
|
||||||
|
///
|
||||||
|
/// Validates shape and ordered-set uniqueness, but cannot validate ancestry or
|
||||||
|
/// root/non-root placement without context. Missing and empty descriptions remain
|
||||||
|
/// distinct; ref and validator bytes are ignored for display. Input is unmodified.
|
||||||
|
/// See [crate documentation](crate) for limits and caller responsibilities.
|
||||||
|
pub fn render_object(object: &Object) -> Result<String, RenderError> {
|
||||||
|
tokenize_object(object).map(|tokens| to_plain_text(&tokens))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates the complete owned display token sequence for one Object signature.
|
||||||
|
///
|
||||||
|
/// Uses the same validation, limits, metadata exclusion and caller responsibilities
|
||||||
|
/// as [`render_object`]. No partial sequence is returned on error. Input is not
|
||||||
|
/// modified. See [`Token`] and [`TokenKind`] for semantic roles and splitting.
|
||||||
|
pub fn tokenize_object(object: &Object) -> Result<Vec<Token>, RenderError> {
|
||||||
|
limits::object(object)?;
|
||||||
|
object.validate().map_err(RenderError::InvalidObject)?;
|
||||||
|
Ok(render::object(object))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders one complete Interface signature from one paired input entity.
|
||||||
|
///
|
||||||
|
/// Validates reference shape and all descriptor declarations/named references,
|
||||||
|
/// including cycles, before checking format support. No lookup or Object is
|
||||||
|
/// needed. All types precede all operations, with original member order; only
|
||||||
|
/// documentation summaries are shown, without interpreting Host text.
|
||||||
|
/// Input is unmodified. See [crate documentation](crate) for safety limits.
|
||||||
|
pub fn render_interface(interface: &Interface<'_>) -> Result<String, RenderError> {
|
||||||
|
tokenize_interface(interface).map(|tokens| to_plain_text(&tokens))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates the complete owned display token sequence for one Interface signature.
|
||||||
|
///
|
||||||
|
/// Uses the same validation, limits, format support and summary-only documentation
|
||||||
|
/// as [`render_interface`]. No partial sequence is returned on error. Input is not
|
||||||
|
/// modified. See [`Token`] and [`TokenKind`] for semantic roles and splitting.
|
||||||
|
/// This traverses structured declarations directly, not a rendered-text lexer.
|
||||||
|
pub fn tokenize_interface(interface: &Interface<'_>) -> Result<Vec<Token>, RenderError> {
|
||||||
|
limits::interface(interface)?;
|
||||||
|
interface
|
||||||
|
.reference
|
||||||
|
.validate()
|
||||||
|
.map_err(RenderError::InvalidReference)?;
|
||||||
|
interface
|
||||||
|
.descriptor
|
||||||
|
.validate()
|
||||||
|
.map_err(RenderError::InvalidDescriptor)?;
|
||||||
|
if interface.descriptor.format != INTERFACE_FORMAT_V1 {
|
||||||
|
return Err(RenderError::UnsupportedDescriptorFormat {
|
||||||
|
format: interface.descriptor.format.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(render::interface(interface))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep both README examples executable as external-consumer doctests.
|
||||||
|
#[cfg(doctest)]
|
||||||
|
#[doc = include_str!("../README.md")]
|
||||||
|
mod readme {}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
use wip_protocol::{Documentation, InterfaceReference, Object, TypeExpr};
|
||||||
|
|
||||||
|
use crate::{Interface, RenderError, ResourceLimit};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Budget {
|
||||||
|
bytes: usize,
|
||||||
|
nodes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(value: usize, max: usize, limit: ResourceLimit) -> Result<(), RenderError> {
|
||||||
|
if value > max {
|
||||||
|
Err(RenderError::ResourceLimit { limit })
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Budget {
|
||||||
|
fn bytes(&mut self, len: usize) -> Result<(), RenderError> {
|
||||||
|
self.bytes = self.bytes.saturating_add(len);
|
||||||
|
check(self.bytes, 1024 * 1024, ResourceLimit::InputBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nodes(&mut self, len: usize) -> Result<(), RenderError> {
|
||||||
|
self.nodes = self.nodes.saturating_add(len);
|
||||||
|
check(self.nodes, 16_384, ResourceLimit::Nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reference(&mut self, reference: &InterfaceReference) -> Result<(), RenderError> {
|
||||||
|
self.nodes(1)?;
|
||||||
|
self.bytes(reference.scope.len())?;
|
||||||
|
self.bytes(reference.name.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn doc(&mut self, doc: &Option<Documentation>) -> Result<(), RenderError> {
|
||||||
|
if let Some(doc) = doc {
|
||||||
|
self.bytes(doc.summary.len())?;
|
||||||
|
self.bytes(doc.details.as_ref().map_or(0, String::len))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn declaration(&mut self, name: &str, doc: &Option<Documentation>) -> Result<(), RenderError> {
|
||||||
|
self.nodes(1)?;
|
||||||
|
self.bytes(name.len())?;
|
||||||
|
self.doc(doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expr(&mut self, expr: &TypeExpr, depth: usize) -> Result<(), RenderError> {
|
||||||
|
check(depth, 64, ResourceLimit::TypeDepth)?;
|
||||||
|
self.nodes(1)?;
|
||||||
|
match expr {
|
||||||
|
TypeExpr::Named { name } => self.bytes(name.len())?,
|
||||||
|
TypeExpr::List { items } => self.expr(items, depth + 1)?,
|
||||||
|
TypeExpr::Record { fields } => {
|
||||||
|
self.nodes(fields.len())?;
|
||||||
|
for field in fields {
|
||||||
|
self.bytes(field.name.len())?;
|
||||||
|
self.doc(&field.documentation)?;
|
||||||
|
self.expr(&field.r#type, depth + 1)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TypeExpr::Enum { cases } => {
|
||||||
|
self.nodes(cases.len())?;
|
||||||
|
for case in cases {
|
||||||
|
self.bytes(case.name.len())?;
|
||||||
|
self.doc(&case.documentation)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TypeExpr::Union { cases } => {
|
||||||
|
self.nodes(cases.len())?;
|
||||||
|
for case in cases {
|
||||||
|
self.bytes(case.name.len())?;
|
||||||
|
self.doc(&case.documentation)?;
|
||||||
|
if let Some(payload) = &case.payload {
|
||||||
|
self.expr(payload, depth + 1)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn object(object: &Object) -> Result<(), RenderError> {
|
||||||
|
let mut budget = Budget::default();
|
||||||
|
budget.bytes(object.name.len())?;
|
||||||
|
budget.bytes(object.description.as_ref().map_or(0, String::len))?;
|
||||||
|
budget.bytes(object.r#ref.as_ref().map_or(0, String::len))?;
|
||||||
|
budget.bytes(object.validator.as_ref().map_or(0, Vec::len))?;
|
||||||
|
check(object.interfaces.len(), 16_384, ResourceLimit::Nodes)?;
|
||||||
|
for reference in &object.interfaces {
|
||||||
|
budget.reference(reference)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn interface(interface: &Interface<'_>) -> Result<(), RenderError> {
|
||||||
|
let mut budget = Budget::default();
|
||||||
|
let descriptor = interface.descriptor;
|
||||||
|
budget.reference(interface.reference)?;
|
||||||
|
budget.bytes(descriptor.format.len())?;
|
||||||
|
budget.doc(&descriptor.documentation)?;
|
||||||
|
check(descriptor.types.len(), 256, ResourceLimit::TypeDeclarations)?;
|
||||||
|
// Check collection counts before traversing even empty names/expressions.
|
||||||
|
check(descriptor.operations.len(), 16_384, ResourceLimit::Nodes)?;
|
||||||
|
for ty in &descriptor.types {
|
||||||
|
budget.declaration(&ty.name, &ty.documentation)?;
|
||||||
|
budget.expr(&ty.definition, 1)?;
|
||||||
|
}
|
||||||
|
for operation in &descriptor.operations {
|
||||||
|
budget.declaration(&operation.name, &operation.documentation)?;
|
||||||
|
check(operation.parameters.len(), 16_384, ResourceLimit::Nodes)?;
|
||||||
|
for parameter in &operation.parameters {
|
||||||
|
budget.declaration(¶meter.name, ¶meter.documentation)?;
|
||||||
|
budget.expr(¶meter.r#type, 1)?;
|
||||||
|
}
|
||||||
|
budget.nodes(1)?; // Return declaration.
|
||||||
|
budget.doc(&operation.returns.documentation)?;
|
||||||
|
budget.expr(&operation.returns.r#type, 1)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
use std::fmt::Write as _;
|
||||||
|
use wip_protocol::{Documentation, InterfaceReference, Object, OperationDeclaration, TypeExpr};
|
||||||
|
|
||||||
|
use crate::{Interface, Token, TokenKind};
|
||||||
|
use TokenKind::{
|
||||||
|
CaseName, FieldName, Indentation, Keyword, Newline, OperationName, ParameterName, Reference,
|
||||||
|
Symbol, TypeName, Whitespace,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Quoting a component and quoting the complete reference are separate layers.
|
||||||
|
fn reference(reference: &InterfaceReference) -> String {
|
||||||
|
format!(
|
||||||
|
"{}::{}",
|
||||||
|
component(&reference.scope, true),
|
||||||
|
component(&reference.name, false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn component(value: &str, scope: bool) -> String {
|
||||||
|
if !value.is_empty()
|
||||||
|
&& value
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b) || (scope && b == b'/'))
|
||||||
|
{
|
||||||
|
value.into()
|
||||||
|
} else {
|
||||||
|
quote(value, '"')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn identifier(value: &str) -> String {
|
||||||
|
const KEYWORDS: &[&str] = &[
|
||||||
|
"object",
|
||||||
|
"at",
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"interfaces",
|
||||||
|
"interface",
|
||||||
|
"type",
|
||||||
|
"operation",
|
||||||
|
"enum",
|
||||||
|
"union",
|
||||||
|
"unit",
|
||||||
|
"boolean",
|
||||||
|
"integer",
|
||||||
|
"number",
|
||||||
|
"string",
|
||||||
|
"bytes",
|
||||||
|
"json",
|
||||||
|
"entry",
|
||||||
|
];
|
||||||
|
if value
|
||||||
|
.bytes()
|
||||||
|
.next()
|
||||||
|
.is_some_and(|b| b.is_ascii_alphabetic() || b == b'_')
|
||||||
|
&& value
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || b == b'_')
|
||||||
|
&& !KEYWORDS.contains(&value)
|
||||||
|
{
|
||||||
|
value.into()
|
||||||
|
} else {
|
||||||
|
quote(value, '`')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quote(value: &str, delimiter: char) -> String {
|
||||||
|
let mut output = String::new();
|
||||||
|
output.push(delimiter);
|
||||||
|
for ch in value.chars() {
|
||||||
|
match ch {
|
||||||
|
'`' if delimiter == '`' => output.push_str("\\`"),
|
||||||
|
'\\' => output.push_str("\\\\"),
|
||||||
|
'"' => output.push_str("\\\""),
|
||||||
|
'\u{08}' => output.push_str("\\b"),
|
||||||
|
'\u{0c}' => output.push_str("\\f"),
|
||||||
|
'\n' => output.push_str("\\n"),
|
||||||
|
'\r' => output.push_str("\\r"),
|
||||||
|
'\t' => output.push_str("\\t"),
|
||||||
|
'\u{00}'..='\u{1f}'
|
||||||
|
| '\u{7f}'..='\u{9f}'
|
||||||
|
| '\u{2028}'
|
||||||
|
| '\u{2029}'
|
||||||
|
| '\u{061c}'
|
||||||
|
| '\u{200e}'..='\u{200f}'
|
||||||
|
| '\u{202a}'..='\u{202e}'
|
||||||
|
| '\u{2066}'..='\u{2069}' => {
|
||||||
|
write!(output, "\\u{:04x}", ch as u32).expect("String write");
|
||||||
|
}
|
||||||
|
_ => output.push(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.push(delimiter);
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn object(object: &Object) -> Vec<Token> {
|
||||||
|
let mut writer = Writer(Vec::new());
|
||||||
|
writer.word(Keyword, "object");
|
||||||
|
writer.space();
|
||||||
|
writer.symbol("{");
|
||||||
|
writer.newline();
|
||||||
|
writer.indent(1);
|
||||||
|
writer.word(Keyword, "name");
|
||||||
|
writer.symbol(":");
|
||||||
|
writer.space();
|
||||||
|
writer.word(TokenKind::String, quote(&object.name, '"'));
|
||||||
|
writer.symbol(";");
|
||||||
|
writer.newline();
|
||||||
|
if let Some(description) = &object.description {
|
||||||
|
writer.indent(1);
|
||||||
|
writer.word(Keyword, "description");
|
||||||
|
writer.symbol(":");
|
||||||
|
writer.space();
|
||||||
|
writer.word(TokenKind::String, quote(description, '"'));
|
||||||
|
writer.symbol(";");
|
||||||
|
writer.newline();
|
||||||
|
}
|
||||||
|
writer.indent(1);
|
||||||
|
writer.word(Keyword, "interfaces");
|
||||||
|
writer.symbol(":");
|
||||||
|
writer.space();
|
||||||
|
writer.symbol("[");
|
||||||
|
for (index, interface) in object.interfaces.iter().enumerate() {
|
||||||
|
if index > 0 {
|
||||||
|
writer.symbol(",");
|
||||||
|
writer.space();
|
||||||
|
}
|
||||||
|
writer.word(Reference, quote(&reference(interface), '"'));
|
||||||
|
}
|
||||||
|
writer.symbol("]");
|
||||||
|
writer.symbol(";");
|
||||||
|
writer.newline();
|
||||||
|
writer.symbol("}");
|
||||||
|
writer.newline();
|
||||||
|
writer.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn interface(interface: &Interface<'_>) -> Vec<Token> {
|
||||||
|
let mut writer = Writer(Vec::new());
|
||||||
|
writer.doc(&interface.descriptor.documentation, 0);
|
||||||
|
writer.word(Keyword, "interface");
|
||||||
|
writer.space();
|
||||||
|
writer.word(Reference, quote(&reference(interface.reference), '"'));
|
||||||
|
writer.space();
|
||||||
|
writer.symbol("{");
|
||||||
|
writer.newline();
|
||||||
|
for declaration in &interface.descriptor.types {
|
||||||
|
writer.doc(&declaration.documentation, 1);
|
||||||
|
writer.indent(1);
|
||||||
|
writer.word(Keyword, "type");
|
||||||
|
writer.space();
|
||||||
|
writer.word(TypeName, identifier(&declaration.name));
|
||||||
|
writer.space();
|
||||||
|
writer.symbol("=");
|
||||||
|
writer.space();
|
||||||
|
writer.expr(&declaration.definition, 1);
|
||||||
|
writer.symbol(";");
|
||||||
|
writer.newline();
|
||||||
|
}
|
||||||
|
for operation in &interface.descriptor.operations {
|
||||||
|
writer.operation(operation);
|
||||||
|
}
|
||||||
|
writer.symbol("}");
|
||||||
|
writer.newline();
|
||||||
|
writer.0
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Writer(Vec<Token>);
|
||||||
|
|
||||||
|
impl Writer {
|
||||||
|
fn word(&mut self, kind: TokenKind, text: impl Into<String>) {
|
||||||
|
self.0.push(Token {
|
||||||
|
kind,
|
||||||
|
text: text.into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn symbol(&mut self, text: &str) {
|
||||||
|
self.word(Symbol, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn space(&mut self) {
|
||||||
|
self.word(Whitespace, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn newline(&mut self) {
|
||||||
|
self.word(Newline, "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn indent(&mut self, depth: usize) {
|
||||||
|
if depth > 0 {
|
||||||
|
self.word(Indentation, " ".repeat(depth));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn doc(&mut self, doc: &Option<Documentation>, depth: usize) {
|
||||||
|
if let Some(doc) = doc {
|
||||||
|
self.indent(depth);
|
||||||
|
self.word(
|
||||||
|
TokenKind::Documentation,
|
||||||
|
format!("/// {}", quote(&doc.summary, '"')),
|
||||||
|
);
|
||||||
|
self.newline();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn member(&mut self, kind: TokenKind, name: &str, required: bool, ty: &TypeExpr, depth: usize) {
|
||||||
|
self.indent(depth);
|
||||||
|
self.word(kind, identifier(name));
|
||||||
|
if !required {
|
||||||
|
self.symbol("?");
|
||||||
|
}
|
||||||
|
self.symbol(":");
|
||||||
|
self.space();
|
||||||
|
self.expr(ty, depth);
|
||||||
|
self.symbol(",");
|
||||||
|
self.newline();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn operation(&mut self, operation: &OperationDeclaration) {
|
||||||
|
self.doc(&operation.documentation, 1);
|
||||||
|
self.indent(1);
|
||||||
|
self.word(Keyword, "operation");
|
||||||
|
self.space();
|
||||||
|
self.word(OperationName, identifier(&operation.name));
|
||||||
|
self.symbol("(");
|
||||||
|
if !operation.parameters.is_empty() {
|
||||||
|
self.newline();
|
||||||
|
for parameter in &operation.parameters {
|
||||||
|
self.doc(¶meter.documentation, 2);
|
||||||
|
self.member(
|
||||||
|
ParameterName,
|
||||||
|
¶meter.name,
|
||||||
|
parameter.required,
|
||||||
|
¶meter.r#type,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.indent(1);
|
||||||
|
}
|
||||||
|
self.symbol(")");
|
||||||
|
if operation.returns.documentation.is_some() {
|
||||||
|
self.newline();
|
||||||
|
self.doc(&operation.returns.documentation, 1);
|
||||||
|
self.indent(1);
|
||||||
|
} else {
|
||||||
|
self.space();
|
||||||
|
}
|
||||||
|
self.symbol("->");
|
||||||
|
self.space();
|
||||||
|
self.expr(&operation.returns.r#type, 1);
|
||||||
|
self.symbol(";");
|
||||||
|
self.newline();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starts at the current cursor. Composite closing braces align with the
|
||||||
|
// declaration/field/parameter containing this expression, not its width.
|
||||||
|
fn expr(&mut self, expr: &TypeExpr, depth: usize) {
|
||||||
|
match expr {
|
||||||
|
TypeExpr::Unit => self.word(Keyword, "unit"),
|
||||||
|
TypeExpr::Boolean => self.word(Keyword, "boolean"),
|
||||||
|
TypeExpr::Integer => self.word(Keyword, "integer"),
|
||||||
|
TypeExpr::Number => self.word(Keyword, "number"),
|
||||||
|
TypeExpr::String => self.word(Keyword, "string"),
|
||||||
|
TypeExpr::Bytes => self.word(Keyword, "bytes"),
|
||||||
|
TypeExpr::Json => self.word(Keyword, "json"),
|
||||||
|
TypeExpr::Entry => self.word(Keyword, "entry"),
|
||||||
|
TypeExpr::Named { name } => self.word(TypeName, identifier(name)),
|
||||||
|
TypeExpr::List { items } => {
|
||||||
|
self.symbol("[");
|
||||||
|
self.expr(items, depth);
|
||||||
|
self.symbol("]");
|
||||||
|
}
|
||||||
|
TypeExpr::Record { fields } => {
|
||||||
|
self.symbol("{");
|
||||||
|
if !fields.is_empty() {
|
||||||
|
self.newline();
|
||||||
|
for field in fields {
|
||||||
|
self.doc(&field.documentation, depth + 1);
|
||||||
|
self.member(
|
||||||
|
FieldName,
|
||||||
|
&field.name,
|
||||||
|
field.required,
|
||||||
|
&field.r#type,
|
||||||
|
depth + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.indent(depth);
|
||||||
|
}
|
||||||
|
self.symbol("}");
|
||||||
|
}
|
||||||
|
TypeExpr::Enum { cases } => {
|
||||||
|
self.word(Keyword, "enum");
|
||||||
|
self.space();
|
||||||
|
self.symbol("{");
|
||||||
|
if !cases.is_empty() {
|
||||||
|
self.newline();
|
||||||
|
for case in cases {
|
||||||
|
self.doc(&case.documentation, depth + 1);
|
||||||
|
self.indent(depth + 1);
|
||||||
|
self.word(CaseName, identifier(&case.name));
|
||||||
|
self.symbol(",");
|
||||||
|
self.newline();
|
||||||
|
}
|
||||||
|
self.indent(depth);
|
||||||
|
}
|
||||||
|
self.symbol("}");
|
||||||
|
}
|
||||||
|
TypeExpr::Union { cases } => {
|
||||||
|
self.word(Keyword, "union");
|
||||||
|
self.space();
|
||||||
|
self.symbol("{");
|
||||||
|
if !cases.is_empty() {
|
||||||
|
self.newline();
|
||||||
|
for case in cases {
|
||||||
|
self.doc(&case.documentation, depth + 1);
|
||||||
|
self.indent(depth + 1);
|
||||||
|
self.word(CaseName, identifier(&case.name));
|
||||||
|
if let Some(payload) = &case.payload {
|
||||||
|
self.symbol("(");
|
||||||
|
self.expr(payload, depth + 1);
|
||||||
|
self.symbol(")");
|
||||||
|
}
|
||||||
|
self.symbol(",");
|
||||||
|
self.newline();
|
||||||
|
}
|
||||||
|
self.indent(depth);
|
||||||
|
}
|
||||||
|
self.symbol("}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
//! Owned display data, independent of the Protocol wire model.
|
||||||
|
|
||||||
|
/// Semantic display role assigned from the input structure, not from text parsing.
|
||||||
|
///
|
||||||
|
/// Identifiers retain their role even when quoted or identical to a keyword.
|
||||||
|
/// Type declarations and Named expressions both use [`Self::TypeName`]. Enum and
|
||||||
|
/// union cases both use [`Self::CaseName`]. See [`Token`] for splitting rules.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum TokenKind {
|
||||||
|
/// Grammar keyword, including Object property labels and primitive types.
|
||||||
|
Keyword,
|
||||||
|
/// Named type declaration or reference within the same Interface.
|
||||||
|
TypeName,
|
||||||
|
/// Operation declaration name (not an invocation target).
|
||||||
|
OperationName,
|
||||||
|
/// Operation parameter name.
|
||||||
|
ParameterName,
|
||||||
|
/// Record field name.
|
||||||
|
FieldName,
|
||||||
|
/// Enum or union case name.
|
||||||
|
CaseName,
|
||||||
|
/// Quoted Object name or description.
|
||||||
|
String,
|
||||||
|
/// Entire quoted absolute Interface reference, including both quoting layers.
|
||||||
|
Reference,
|
||||||
|
/// Entire `/// "summary"` line content, excluding indentation and LF.
|
||||||
|
Documentation,
|
||||||
|
/// One punctuation mark, or the complete two-character return arrow `->`.
|
||||||
|
Symbol,
|
||||||
|
/// One inter-token ASCII space, never indentation or text inside quotes.
|
||||||
|
Whitespace,
|
||||||
|
/// One LF (`\n`), including the final signature LF.
|
||||||
|
Newline,
|
||||||
|
/// All leading spaces on a non-root line: two ASCII spaces per depth level.
|
||||||
|
Indentation,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One owned, already escaped fragment of a Text View signature.
|
||||||
|
///
|
||||||
|
/// Each keyword, identifier, quoted string and complete quoted reference is one
|
||||||
|
/// token, including its delimiters and escapes. Documentation is one token for
|
||||||
|
/// the `///` marker, space and quoted summary together. Symbols are individual
|
||||||
|
/// punctuation marks except `->`, which is indivisible. Spaces between tokens,
|
||||||
|
/// each LF, and each nonzero line indentation have separate tokens. No empty
|
||||||
|
/// tokens are emitted; an empty value still has its quotes. Adjacent symbols are
|
||||||
|
/// not merged. Host whitespace inside quoted data stays inside that data token.
|
||||||
|
///
|
||||||
|
/// The generation APIs emit all layout: concatenating [`Self::text`] in order,
|
||||||
|
/// without separators, produces exactly the existing plain-text signature.
|
||||||
|
/// Tokens own their text and can outlive the input; they are display data, not
|
||||||
|
/// an AST, lexer output, Protocol serialization or identity/call-target data.
|
||||||
|
///
|
||||||
|
/// Text is escaped for **WIP Text View**, not for HTML or any other destination.
|
||||||
|
/// Custom renderers must apply destination-specific escaping and must not treat
|
||||||
|
/// Host documentation as markup or instructions.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Token {
|
||||||
|
pub(crate) kind: TokenKind,
|
||||||
|
pub(crate) text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Token {
|
||||||
|
/// Returns the semantic display role, without reparsing the output text.
|
||||||
|
pub fn kind(&self) -> TokenKind {
|
||||||
|
self.kind
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the exact output fragment, with WIP quoting and escapes applied.
|
||||||
|
pub fn text(&self) -> &str {
|
||||||
|
&self.text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Concatenates token text in order, with no separators or further processing.
|
||||||
|
///
|
||||||
|
/// This performs no grammar interpretation, validation, quoting, escaping or
|
||||||
|
/// layout completion. An empty slice yields an empty string. Filtering or
|
||||||
|
/// reordering tokens is allowed, but need not produce a complete signature.
|
||||||
|
/// Object/Interface generation limits are checked by [`crate::tokenize_object`]
|
||||||
|
/// and [`crate::tokenize_interface`], not by this concatenation function.
|
||||||
|
pub fn to_plain_text(tokens: &[Token]) -> String {
|
||||||
|
tokens.iter().map(Token::text).collect()
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
interface "/::example" {
|
||||||
|
type `string` = {
|
||||||
|
`表示名`: string,
|
||||||
|
};
|
||||||
|
operation `find-item`(
|
||||||
|
`search term`: string,
|
||||||
|
) -> `string`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/// "Issueの内容と関連項目を読む。"
|
||||||
|
interface "/::issue.read" {
|
||||||
|
type Relation = enum {
|
||||||
|
parent,
|
||||||
|
sibling,
|
||||||
|
related,
|
||||||
|
};
|
||||||
|
type RelatedItems = {
|
||||||
|
/// "同じWorldspace内の関連項目。"
|
||||||
|
items: [entry],
|
||||||
|
next_cursor?: string,
|
||||||
|
};
|
||||||
|
/// "本文を取得する。"
|
||||||
|
operation read_body() -> {
|
||||||
|
body: string,
|
||||||
|
};
|
||||||
|
/// "関係を指定して関連項目を取得する。"
|
||||||
|
operation get_related_items(
|
||||||
|
relation: Relation,
|
||||||
|
limit?: integer,
|
||||||
|
) -> RelatedItems;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
interface "/::example" {
|
||||||
|
/// "type summary"
|
||||||
|
type Z = {
|
||||||
|
/// "field summary"
|
||||||
|
z?: unit,
|
||||||
|
a: [union {
|
||||||
|
/// "case summary"
|
||||||
|
z,
|
||||||
|
/// ""
|
||||||
|
a(unit),
|
||||||
|
nested(enum {
|
||||||
|
/// "enum summary"
|
||||||
|
z,
|
||||||
|
a,
|
||||||
|
}),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
operation z(
|
||||||
|
z: unit,
|
||||||
|
a?: unit,
|
||||||
|
) -> unit;
|
||||||
|
operation a()
|
||||||
|
/// "no params result"
|
||||||
|
-> unit;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
object {
|
||||||
|
name: "123";
|
||||||
|
description: "Issue 123の公開操作。本文はOperationで取得する。";
|
||||||
|
interfaces: ["/::issue.read", "/::issue.lifecycle"];
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
interface "/::example" {
|
||||||
|
type CloseResult = union {
|
||||||
|
closed,
|
||||||
|
blocked({
|
||||||
|
dependencies: [entry],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
operation refresh() -> unit;
|
||||||
|
/// "対象を検索する。"
|
||||||
|
operation search(
|
||||||
|
/// "検索語。"
|
||||||
|
query: string,
|
||||||
|
)
|
||||||
|
/// "条件に一致する項目。"
|
||||||
|
-> {
|
||||||
|
items: [entry],
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user