Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4544bc0d8b | ||
|
|
8ecc307fc9 | ||
|
|
bf5fa54f44 | ||
|
|
c6528c2887 | ||
|
|
b196047b57 | ||
|
|
ea49cb0586 | ||
|
|
da559de912 | ||
|
|
193ca89d93 |
@@ -0,0 +1,25 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to the published WIP Rust crates are documented here.
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
- Breaking Protocol/HTTP migration from opaque Interface reference strings to
|
||||||
|
exact `(scope, name)` records in Objects, fetches and calls, with optional
|
||||||
|
opaque `scope_ref` metadata. Adds shape and placement-aware scope validation
|
||||||
|
without changing routes, validators or the wire error-code set.
|
||||||
|
- Mechanically migrates workspace consumers and fixtures. See [MIGRATION.md](MIGRATION.md)
|
||||||
|
for API/wire examples and the deferred Host/Client lifecycle semantics.
|
||||||
|
|
||||||
|
## 0.1.0
|
||||||
|
|
||||||
|
Initial public release of:
|
||||||
|
|
||||||
|
- `wip-protocol`: transport-independent WIP logical model and validation.
|
||||||
|
- `wip-interface-macros`: procedural-macro implementation for interface and DTO declarations.
|
||||||
|
- `wip-interface`: public Rust interface-authoring facade and typed value codecs.
|
||||||
|
- `wip-host`: request-scoped publication, precondition validation, and qualified operation dispatch.
|
||||||
|
- `wip-http`: framework-independent WIP over HTTP v1 routes, strict JSON codecs, and failure classification.
|
||||||
|
- `wip-client`: stateful Known Space, interest-driven acquisition, request ownership, and operation lifecycle.
|
||||||
|
|
||||||
|
The repository also contains non-published filesystem server, terminal client, and public-API conformance packages.
|
||||||
Generated
+2
-2
@@ -1341,9 +1341,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke-derive"
|
name = "yoke-derive"
|
||||||
version = "0.8.3"
|
version = "0.8.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
|
checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|||||||
+16
@@ -1,3 +1,19 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = ["crates/*"]
|
members = ["crates/*"]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
rust-version = "1.88"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
repository = "https://gitea.hareworks.net/Hare/wip-rs"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
wip-client = { version = "0.1.0", path = "crates/wip-client" }
|
||||||
|
wip-fs-server = { version = "0.1.0", path = "crates/wip-fs-server" }
|
||||||
|
wip-host = { version = "0.1.0", path = "crates/wip-host" }
|
||||||
|
wip-http = { version = "0.1.0", path = "crates/wip-http" }
|
||||||
|
wip-interface = { version = "0.1.0", path = "crates/wip-interface" }
|
||||||
|
wip-interface-macros = { version = "0.1.0", path = "crates/wip-interface-macros" }
|
||||||
|
wip-protocol = { version = "0.1.0", path = "crates/wip-protocol" }
|
||||||
|
|||||||
+202
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Hare
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
# Structured Interface reference migration
|
||||||
|
|
||||||
|
This unreleased API/wire change follows `wip-reference` canonical Core Model,
|
||||||
|
Protocol and WIP over HTTP at `6086f3c5ef10aa1464ce9c824750d7f667217bfd`.
|
||||||
|
It replaces the earlier opaque Interface reference contract; HTTP routes and
|
||||||
|
error codes have **not** changed. Peers must upgrade together: the codec does
|
||||||
|
not accept legacy strings or display shorthand as Protocol references.
|
||||||
|
|
||||||
|
## Rust API
|
||||||
|
|
||||||
|
`Object.interfaces` is now `Vec<InterfaceReference>`.
|
||||||
|
`FetchInterfaceRequest.interface`, `FetchInterfaceResponse.interface`, and
|
||||||
|
`InterfaceTarget.reference` all use the same record:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wip_protocol::{FetchInterfaceRequest, InterfaceReference, InterfaceTarget};
|
||||||
|
|
||||||
|
let reference = InterfaceReference {
|
||||||
|
scope: "/github".into(),
|
||||||
|
name: "issue.read".into(),
|
||||||
|
};
|
||||||
|
reference.validate()?; // reference shape, no target Object path needed
|
||||||
|
let fetch = FetchInterfaceRequest { interface: reference.clone() };
|
||||||
|
let target = InterfaceTarget {
|
||||||
|
reference,
|
||||||
|
scope_ref: Some("opaque-scope-object-ref".into()),
|
||||||
|
validator: None,
|
||||||
|
};
|
||||||
|
# Ok::<(), wip_protocol::ValidationError>(())
|
||||||
|
```
|
||||||
|
|
||||||
|
Construct scope and name explicitly from your Worldspace publication context.
|
||||||
|
There is no implicit conversion from a string, delimiter parser, normalization,
|
||||||
|
case folding, or URL decoding. A name may contain `/`, `.`, `::`, `#`, or Unicode;
|
||||||
|
only an empty name is invalid. Scope must be a canonical absolute Worldspace
|
||||||
|
path: `/`, or nonempty slash-separated segments other than `.` and `..`, with
|
||||||
|
no empty segments or trailing slash. `%2F` is ordinary field text, not an escape
|
||||||
|
for a Worldspace path separator.
|
||||||
|
|
||||||
|
Equality, hashing and ordering operate on both fields, independent of member
|
||||||
|
order/JSON escape spelling. Use the record itself as a membership/cache key;
|
||||||
|
identical names in different scopes are different references. The descriptor
|
||||||
|
still contains only format, documentation, types and operations, not identity.
|
||||||
|
|
||||||
|
`FetchInterfaceResponse` and `InterfaceTarget` additionally require the Rust
|
||||||
|
field `scope_ref: Option<String>` in struct literals. Use `None` when omitted,
|
||||||
|
not an invented identity. This is an opaque scope Object ref observation/call
|
||||||
|
precondition, **not** a third Interface identity component or a validator.
|
||||||
|
The scope-ref lifecycle/atomic Host enforcement and Client cache-recovery
|
||||||
|
semantics are follow-up work (T-21/T-22); this migration supplies the data model
|
||||||
|
and codec, not a completed lifecycle implementation.
|
||||||
|
|
||||||
|
## Validation boundaries
|
||||||
|
|
||||||
|
- `InterfaceReference::validate()` and `FetchInterfaceRequest::validate()`
|
||||||
|
check reference shape independently of a target Object path.
|
||||||
|
- `Object::validate()` checks shape and duplicate `(scope, name)` pairs;
|
||||||
|
`Object::validate_at_path()` additionally checks placement, including scope.
|
||||||
|
`ObserveRequest::validate_response()` checks every derived node path,
|
||||||
|
including the depth boundary. Invalid success responses are Client-local
|
||||||
|
`InvalidResponse`, not Host-returned Protocol errors.
|
||||||
|
- `InterfaceReference::validate_for_path(path)` checks ancestor-or-self scope
|
||||||
|
compatibility by path segments. Root and self scope are valid; `/a` is not
|
||||||
|
an ancestor of `/ab`. This has no indexability dependency.
|
||||||
|
- `CallOperationRequest::validate()` checks descriptor-independent shape.
|
||||||
|
`validate_with()` adds operation/argument checks but does not replace Host
|
||||||
|
publication lookup. Host interface selection checks scope compatibility and
|
||||||
|
exact membership **after** target lookup: invalid reference shape is
|
||||||
|
`InvalidRequest`, but a valid reference outside the target's scope is
|
||||||
|
`InterfaceMismatch`, with `NotFound` retaining precedence.
|
||||||
|
|
||||||
|
## HTTP wire
|
||||||
|
|
||||||
|
All four reference positions use an exact object with required string fields:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"scope":"/github","name":"issue.read"}
|
||||||
|
```
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"interface": {"scope":"/github","name":"issue.read"},
|
||||||
|
"scope_ref": "opaque-scope-object-ref",
|
||||||
|
"descriptor": {"format":"wip-interface/1","types":[],"operations":[]}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A call uses:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"target": {"path":"/github/issues/123"},
|
||||||
|
"interface": {
|
||||||
|
"reference": {"scope":"/github","name":"issue.read"},
|
||||||
|
"scope_ref": "opaque-scope-object-ref"
|
||||||
|
},
|
||||||
|
"operation":"refresh",
|
||||||
|
"arguments":{}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Unknown/duplicate members, missing fields, null fields, relative scopes, empty
|
||||||
|
names, legacy `"interface-v1"` and shorthand `"/github::issue.read"` strings
|
||||||
|
are rejected. Field order and JSON escaping do not affect decoded identity.
|
||||||
|
`scope_ref` is a normal opaque string, **not Base64**; omission is allowed but
|
||||||
|
explicit null and nonstrings are invalid. An empty string is a valid opaque
|
||||||
|
ref. Validators remain canonical padded standard Base64.
|
||||||
|
|
||||||
|
Descriptor-independent HTTP call metadata decoding enforces reference shape
|
||||||
|
and the same strict JSON rules without turning scope compatibility into early
|
||||||
|
`InvalidRequest`. Normal descriptor-bound encode/decode uses the same reference
|
||||||
|
codec. Request violations retain the existing `400 invalid_request` boundary;
|
||||||
|
malformed success responses retain Client-local `InvalidResponse`. Standard
|
||||||
|
`POST v1/observe`, `v1/fetch_interface`, `v1/call_operation`, error-code/status
|
||||||
|
mapping and Transport failure classification are unchanged.
|
||||||
|
|
||||||
|
## Downstream status
|
||||||
|
|
||||||
|
The workspace Client, Host, filesystem server, TUI and public-API example
|
||||||
|
are mechanically migrated to structured protocol values and updated fixtures.
|
||||||
|
The existing Host's generated opaque registration handle is bridged explicitly
|
||||||
|
to scope `/` with its full handle as the name; no delimiter parsing or scope
|
||||||
|
registration/lifetime model is introduced here. The existing Client keys use
|
||||||
|
structured values; this change does not implement scope deletion/replacement
|
||||||
|
cache recovery. TUI formatting is only the type-compatibility adaptation, not
|
||||||
|
the full canonical Text View work (T-24). Filesystem/TUI semantics integration
|
||||||
|
remains T-23. Interface authoring/macros are descriptor-only and require no
|
||||||
|
identity fields or declaration changes.
|
||||||
@@ -13,29 +13,36 @@ repository root is not itself a package.
|
|||||||
## Crate graph and responsibilities
|
## Crate graph and responsibilities
|
||||||
|
|
||||||
```text
|
```text
|
||||||
wip-tui (interactive Worldspace explorer and operation editor)
|
Published libraries
|
||||||
├── wip-client Known Space, request identity, validators, and calls
|
|
||||||
├── wip-http framework-neutral HTTP request/response messages
|
|
||||||
└── wip-protocol descriptors and logical values
|
|
||||||
|
|
||||||
wip-fs-server (request-time filesystem projection and loopback listener)
|
wip-client
|
||||||
├── wip-http standard routes/codecs/envelopes
|
├── wip-http
|
||||||
├── wip-host request-scoped publication and dispatch
|
│ └── wip-protocol
|
||||||
└── wip-interface macro-generated filesystem capabilities
|
└── wip-protocol
|
||||||
|
|
||||||
wip-public-api-example (workspace-only executable, integration tests, doctest)
|
wip-host
|
||||||
├── wip-protocol logical declarations, values, calls, and validation
|
├── wip-interface
|
||||||
├── wip-http standard HTTP v1 routes, JSON codecs, and envelopes
|
│ ├── wip-interface-macros
|
||||||
│ └── wip-protocol
|
│ └── wip-protocol
|
||||||
├── wip-client stateful Known Space, lazy tree, observation, and call runtime
|
└── wip-protocol
|
||||||
│ ├── wip-http
|
|
||||||
│ └── wip-protocol
|
Workspace-only applications and conformance consumers
|
||||||
├── wip-interface application DTO codecs and interface authoring facade
|
|
||||||
│ ├── wip-protocol
|
wip-tui
|
||||||
│ └── wip-interface-macros proc-macro implementation detail
|
├── wip-client
|
||||||
└── wip-host descriptor registry, publication, and qualified dispatch
|
├── wip-http
|
||||||
├── wip-interface
|
└── wip-protocol
|
||||||
└── wip-protocol
|
|
||||||
|
wip-fs-server
|
||||||
|
├── wip-host
|
||||||
|
├── wip-http
|
||||||
|
├── wip-interface
|
||||||
|
└── wip-protocol
|
||||||
|
|
||||||
|
wip-public-api-example
|
||||||
|
├── wip-host
|
||||||
|
├── wip-interface
|
||||||
|
└── wip-protocol
|
||||||
```
|
```
|
||||||
|
|
||||||
| Crate | Responsibility | Does not own |
|
| Crate | Responsibility | Does not own |
|
||||||
@@ -50,6 +57,21 @@ wip-public-api-example (workspace-only executable, integration tests, doctest)
|
|||||||
| `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
|
||||||
|
|
||||||
|
The `0.1.0` release consists of these six crates:
|
||||||
|
|
||||||
|
- `wip-protocol`
|
||||||
|
- `wip-interface-macros` — implementation dependency re-exported by `wip-interface`
|
||||||
|
- `wip-interface`
|
||||||
|
- `wip-host`
|
||||||
|
- `wip-http`
|
||||||
|
- `wip-client`
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
See [RELEASING.md](RELEASING.md) for the dependency-ordered crates.io procedure and [CHANGELOG.md](CHANGELOG.md) for release contents.
|
||||||
|
|
||||||
The boundary is intentional: protocol types describe what can cross a
|
The boundary is intentional: protocol types describe what can cross a
|
||||||
transport; application DTOs opt into that model with `WipType`; a host-private
|
transport; application DTOs opt into that model with `WipType`; a host-private
|
||||||
implementation owns credentials, handles, and backend state; and `wip-http`
|
implementation owns credentials, handles, and backend state; and `wip-http`
|
||||||
@@ -57,6 +79,13 @@ maps standard routes/envelopes to `CallOperationRequest` and
|
|||||||
`CallOperationResponse` without placing transport-specific types in descriptors
|
`CallOperationResponse` without placing transport-specific types in descriptors
|
||||||
or application traits.
|
or application traits.
|
||||||
|
|
||||||
|
## Structured Interface references
|
||||||
|
|
||||||
|
The unreleased Protocol/HTTP API uses explicit `InterfaceReference { scope,
|
||||||
|
name }` records and optional `scope_ref` observation/precondition metadata.
|
||||||
|
Legacy opaque-string wire references are rejected. See [MIGRATION.md](MIGRATION.md)
|
||||||
|
for construction examples, validation boundaries and downstream migration status.
|
||||||
|
|
||||||
## End-to-end example
|
## End-to-end example
|
||||||
|
|
||||||
[`crates/wip-public-api-example/src/lib.rs`](crates/wip-public-api-example/src/lib.rs)
|
[`crates/wip-public-api-example/src/lib.rs`](crates/wip-public-api-example/src/lib.rs)
|
||||||
@@ -69,7 +98,7 @@ runs it. It demonstrates all of the following:
|
|||||||
2. two public traits declared with `#[wip::interface]`;
|
2. two public traits declared with `#[wip::interface]`;
|
||||||
3. a private host struct implementing both traits;
|
3. a private host struct implementing both traits;
|
||||||
4. explicit publication of both interfaces as an ordered set on one object;
|
4. explicit publication of both interfaces as an ordered set on one object;
|
||||||
5. separate observation of opaque interface references, descriptors, and
|
5. separate observation of structured interface references, descriptors, and
|
||||||
descriptor validators;
|
descriptor validators;
|
||||||
6. calls qualified by `target.path`, object validator, interface reference and
|
6. calls qualified by `target.path`, object validator, interface reference and
|
||||||
validator, operation, and schema-neutral `Value` arguments;
|
validator, operation, and schema-neutral `Value` arguments;
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Preconditions
|
||||||
|
|
||||||
|
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]`.
|
||||||
|
3. Confirm ownership of all six names on crates.io.
|
||||||
|
4. Run the complete repository validation suite:
|
||||||
|
|
||||||
|
```console
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo check --workspace --all-targets
|
||||||
|
cargo test --workspace --all-targets
|
||||||
|
cargo test --workspace --doc
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
RUSTDOCFLAGS='-D warnings' cargo doc --workspace --no-deps
|
||||||
|
cargo run --package wip-public-api-example
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Publish order
|
||||||
|
|
||||||
|
Before uploading anything, dry-run the exact public package set together. Cargo uses a temporary local registry for unpublished workspace dependencies during this check:
|
||||||
|
|
||||||
|
```console
|
||||||
|
cargo publish --dry-run --workspace \
|
||||||
|
--exclude wip-fs-server \
|
||||||
|
--exclude wip-public-api-example \
|
||||||
|
--exclude wip-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
Then dry-run and publish each layer before moving to a crate that depends on it:
|
||||||
|
|
||||||
|
```console
|
||||||
|
cargo publish --dry-run --package wip-protocol
|
||||||
|
cargo publish --package wip-protocol
|
||||||
|
|
||||||
|
cargo publish --dry-run --package wip-interface-macros
|
||||||
|
cargo publish --package wip-interface-macros
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait until both versions are available from the crates.io index, then continue:
|
||||||
|
|
||||||
|
```console
|
||||||
|
cargo publish --dry-run --package wip-interface
|
||||||
|
cargo publish --package wip-interface
|
||||||
|
|
||||||
|
cargo publish --dry-run --package wip-http
|
||||||
|
cargo publish --package wip-http
|
||||||
|
```
|
||||||
|
|
||||||
|
After those versions are available from the index:
|
||||||
|
|
||||||
|
```console
|
||||||
|
cargo publish --dry-run --package wip-host
|
||||||
|
cargo publish --package wip-host
|
||||||
|
|
||||||
|
cargo publish --dry-run --package wip-client
|
||||||
|
cargo publish --package wip-client
|
||||||
|
```
|
||||||
|
|
||||||
|
Registry propagation can take time. A dependent dry-run reporting that a WIP crate is unavailable means the previous publish is not visible in the selected registry yet; do not bypass verification.
|
||||||
|
|
||||||
|
## Tag
|
||||||
|
|
||||||
|
After all six uploads resolve successfully, tag the exact published commit and push the tag:
|
||||||
|
|
||||||
|
```console
|
||||||
|
git tag -a v0.1.0 -m "release: 0.1.0"
|
||||||
|
git push origin v0.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not move or recreate a published version or its tag. Prepare a new version for any subsequent correction.
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wip-client"
|
name = "wip-client"
|
||||||
version = "0.1.0"
|
version.workspace = true
|
||||||
edition = "2024"
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
description = "Presentation-independent stateful WIP client runtime"
|
description = "Presentation-independent stateful WIP client runtime"
|
||||||
license = "MIT OR Apache-2.0"
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
readme = "README.md"
|
||||||
|
documentation = "https://docs.rs/wip-client"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
wip-http = { path = "../wip-http" }
|
wip-http.workspace = true
|
||||||
wip-protocol = { path = "../wip-protocol" }
|
wip-protocol.workspace = true
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Hare
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# wip-client
|
||||||
|
|
||||||
|
Presentation-independent stateful runtime for Web 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.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
wip-client = "0.1.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.
|
||||||
|
|
||||||
|
Interface acquisition and call APIs use `wip_protocol::InterfaceReference { scope, name }` directly. Interface caches and Object membership compare the complete structured value, so equal names in different scopes remain distinct. Interface observations retain the optional `scope_ref`, and calls copy it into `InterfaceTarget` unchanged. Scope-identity recovery is not implemented by this mechanical migration.
|
||||||
|
|
||||||
|
See the [API documentation](https://docs.rs/wip-client) for session, acquisition, cache, and call APIs.
|
||||||
|
|
||||||
|
Licensed under either of Apache-2.0 or MIT at your option.
|
||||||
@@ -21,8 +21,8 @@ use wip_http::{
|
|||||||
};
|
};
|
||||||
use wip_protocol::{
|
use wip_protocol::{
|
||||||
CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, InterfaceDescriptor,
|
CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, InterfaceDescriptor,
|
||||||
InterfaceTarget, Object, ObjectObservation as ProtocolObjectObservation, ObserveRequest,
|
InterfaceReference, InterfaceTarget, Object, ObjectObservation as ProtocolObjectObservation,
|
||||||
OperationDeclaration, ProtocolError, ProtocolErrorCode, Target, Value,
|
ObserveRequest, OperationDeclaration, ProtocolError, ProtocolErrorCode, Target, Value,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Monotonic identity assigned to one logical request.
|
/// Monotonic identity assigned to one logical request.
|
||||||
@@ -226,12 +226,14 @@ pub struct TreeObservation {
|
|||||||
/// Cached interface descriptor observation.
|
/// Cached interface descriptor observation.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct InterfaceObservation {
|
pub struct InterfaceObservation {
|
||||||
/// Opaque interface reference.
|
/// Structured interface reference.
|
||||||
pub reference: String,
|
pub reference: InterfaceReference,
|
||||||
/// Last accepted descriptor, if one has been received.
|
/// Last accepted descriptor, if one has been received.
|
||||||
pub descriptor: Option<InterfaceDescriptor>,
|
pub descriptor: Option<InterfaceDescriptor>,
|
||||||
/// Optional interface validator. Absence makes the reference immutable.
|
/// Optional interface validator. Absence makes the reference immutable.
|
||||||
pub validator: Option<Vec<u8>>,
|
pub validator: Option<Vec<u8>>,
|
||||||
|
/// Optional opaque identity of the interface scope Object.
|
||||||
|
pub scope_ref: Option<String>,
|
||||||
/// Current observation lifecycle.
|
/// Current observation lifecycle.
|
||||||
pub state: ObservationState,
|
pub state: ObservationState,
|
||||||
}
|
}
|
||||||
@@ -404,7 +406,7 @@ struct Session {
|
|||||||
objects: BTreeMap<u64, ObjectRecord>,
|
objects: BTreeMap<u64, ObjectRecord>,
|
||||||
refs: BTreeMap<String, u64>,
|
refs: BTreeMap<String, u64>,
|
||||||
trees: BTreeMap<String, TreeObservation>,
|
trees: BTreeMap<String, TreeObservation>,
|
||||||
interfaces: BTreeMap<String, InterfaceRecord>,
|
interfaces: BTreeMap<InterfaceReference, InterfaceRecord>,
|
||||||
calls: VecDeque<CallRecord>,
|
calls: VecDeque<CallRecord>,
|
||||||
diagnostics: VecDeque<Diagnostic>,
|
diagnostics: VecDeque<Diagnostic>,
|
||||||
next_object_key: u64,
|
next_object_key: u64,
|
||||||
@@ -596,7 +598,11 @@ impl Client {
|
|||||||
|
|
||||||
/// Returns the current interface observation.
|
/// Returns the current interface observation.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn interface(&self, session: &SessionId, reference: &str) -> Option<&InterfaceObservation> {
|
pub fn interface(
|
||||||
|
&self,
|
||||||
|
session: &SessionId,
|
||||||
|
reference: &InterfaceReference,
|
||||||
|
) -> Option<&InterfaceObservation> {
|
||||||
self.sessions
|
self.sessions
|
||||||
.get(session)?
|
.get(session)?
|
||||||
.interfaces
|
.interfaces
|
||||||
@@ -762,9 +768,8 @@ impl Client {
|
|||||||
pub fn ensure_interface(
|
pub fn ensure_interface(
|
||||||
&mut self,
|
&mut self,
|
||||||
session: &SessionId,
|
session: &SessionId,
|
||||||
reference: impl Into<String>,
|
reference: InterfaceReference,
|
||||||
) -> Result<Option<PreparedRequest>, ClientError> {
|
) -> Result<Option<PreparedRequest>, ClientError> {
|
||||||
let reference = reference.into();
|
|
||||||
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
|
||||||
@@ -782,11 +787,11 @@ impl Client {
|
|||||||
pub fn prepare_interface(
|
pub fn prepare_interface(
|
||||||
&mut self,
|
&mut self,
|
||||||
session: &SessionId,
|
session: &SessionId,
|
||||||
reference: impl Into<String>,
|
reference: InterfaceReference,
|
||||||
) -> Result<PreparedRequest, ClientError> {
|
) -> Result<PreparedRequest, ClientError> {
|
||||||
self.ensure_capacity()?;
|
self.ensure_capacity()?;
|
||||||
let request = FetchInterfaceRequest {
|
let request = FetchInterfaceRequest {
|
||||||
interface: reference.into(),
|
interface: reference,
|
||||||
};
|
};
|
||||||
let endpoint = session_endpoint(session)?;
|
let endpoint = session_endpoint(session)?;
|
||||||
let encoded = encode_fetch_interface_request(&endpoint, &request, self.wire_limits)
|
let encoded = encode_fetch_interface_request(&endpoint, &request, self.wire_limits)
|
||||||
@@ -804,6 +809,7 @@ impl Client {
|
|||||||
.or_insert(InterfaceRecord {
|
.or_insert(InterfaceRecord {
|
||||||
observation: InterfaceObservation {
|
observation: InterfaceObservation {
|
||||||
reference: request.interface.clone(),
|
reference: request.interface.clone(),
|
||||||
|
scope_ref: None,
|
||||||
descriptor: None,
|
descriptor: None,
|
||||||
validator: None,
|
validator: None,
|
||||||
state: ObservationState::Loading(id),
|
state: ObservationState::Loading(id),
|
||||||
@@ -833,7 +839,7 @@ impl Client {
|
|||||||
&mut self,
|
&mut self,
|
||||||
session: &SessionId,
|
session: &SessionId,
|
||||||
path: &str,
|
path: &str,
|
||||||
interface: &str,
|
interface: &InterfaceReference,
|
||||||
operation: impl Into<String>,
|
operation: impl Into<String>,
|
||||||
arguments: BTreeMap<String, Value>,
|
arguments: BTreeMap<String, Value>,
|
||||||
) -> Result<PreparedRequest, ClientError> {
|
) -> Result<PreparedRequest, ClientError> {
|
||||||
@@ -900,7 +906,8 @@ impl Client {
|
|||||||
validator: object.validator.clone(),
|
validator: object.validator.clone(),
|
||||||
},
|
},
|
||||||
interface: InterfaceTarget {
|
interface: InterfaceTarget {
|
||||||
reference: interface.to_owned(),
|
reference: interface.clone(),
|
||||||
|
scope_ref: interface_observation.scope_ref.clone(),
|
||||||
validator: interface_observation.validator.clone(),
|
validator: interface_observation.validator.clone(),
|
||||||
},
|
},
|
||||||
operation,
|
operation,
|
||||||
@@ -1182,6 +1189,7 @@ impl Client {
|
|||||||
let touched = state.tick();
|
let touched = state.tick();
|
||||||
let observation = InterfaceObservation {
|
let observation = InterfaceObservation {
|
||||||
reference: value.interface,
|
reference: value.interface,
|
||||||
|
scope_ref: value.scope_ref,
|
||||||
descriptor: Some(value.descriptor),
|
descriptor: Some(value.descriptor),
|
||||||
validator: value.validator,
|
validator: value.validator,
|
||||||
state: ObservationState::Fresh,
|
state: ObservationState::Fresh,
|
||||||
@@ -1466,7 +1474,7 @@ impl Client {
|
|||||||
fn interface_request_is_current(
|
fn interface_request_is_current(
|
||||||
&self,
|
&self,
|
||||||
session: &SessionId,
|
session: &SessionId,
|
||||||
reference: &str,
|
reference: &InterfaceReference,
|
||||||
id: RequestId,
|
id: RequestId,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
@@ -1511,7 +1519,7 @@ impl Client {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum RetrievalSubject {
|
enum RetrievalSubject {
|
||||||
Observe(ObserveRequest),
|
Observe(ObserveRequest),
|
||||||
Interface(String),
|
Interface(InterfaceReference),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -1617,7 +1625,7 @@ fn set_observe_subject_state(
|
|||||||
|
|
||||||
fn ensure_interface_slot(
|
fn ensure_interface_slot(
|
||||||
state: &mut Session,
|
state: &mut Session,
|
||||||
reference: &str,
|
reference: &InterfaceReference,
|
||||||
maximum: usize,
|
maximum: usize,
|
||||||
) -> Result<(), ClientError> {
|
) -> Result<(), ClientError> {
|
||||||
if state.interfaces.contains_key(reference) || state.interfaces.len() < maximum {
|
if state.interfaces.contains_key(reference) || state.interfaces.len() < maximum {
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||||
|
wip_protocol::InterfaceReference {
|
||||||
|
scope: "/".into(),
|
||||||
|
name: name.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use wip_client::{
|
use wip_client::{
|
||||||
@@ -39,7 +46,7 @@ fn object() -> Object {
|
|||||||
Object {
|
Object {
|
||||||
name: "item".into(),
|
name: "item".into(),
|
||||||
description: None,
|
description: None,
|
||||||
interfaces: vec!["math".into()],
|
interfaces: vec![root_reference("math")],
|
||||||
r#ref: None,
|
r#ref: None,
|
||||||
validator: Some(b"object-v1".to_vec()),
|
validator: Some(b"object-v1".to_vec()),
|
||||||
}
|
}
|
||||||
@@ -72,7 +79,7 @@ fn malformed_json_unknown_fields_duplicates_and_invalid_success_are_invalid_resp
|
|||||||
let cases: &[&[u8]] = &[
|
let cases: &[&[u8]] = &[
|
||||||
b"{not json",
|
b"{not json",
|
||||||
br#"{"name":"item","interfaces":[],"unknown":true}"#,
|
br#"{"name":"item","interfaces":[],"unknown":true}"#,
|
||||||
br#"{"name":"item","interfaces":["math","math"]}"#,
|
br#"{"name":"item","interfaces":[{"scope":"/","name":"math"},{"scope":"/","name":"math"}]}"#,
|
||||||
br#"{"name":"wrong","interfaces":[]}"#,
|
br#"{"name":"wrong","interfaces":[]}"#,
|
||||||
];
|
];
|
||||||
for body in cases {
|
for body in cases {
|
||||||
@@ -181,12 +188,15 @@ fn non_success_without_valid_envelope_remains_transport_failure() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn unsupported_descriptor_format_is_distinct_from_invalid_response() {
|
fn unsupported_descriptor_format_is_distinct_from_invalid_response() {
|
||||||
let (mut client, session) = setup();
|
let (mut client, session) = setup();
|
||||||
let request = client.prepare_interface(&session, "math").unwrap();
|
let request = client
|
||||||
|
.prepare_interface(&session, root_reference("math"))
|
||||||
|
.unwrap();
|
||||||
let logical = FetchInterfaceRequest {
|
let logical = FetchInterfaceRequest {
|
||||||
interface: "math".into(),
|
interface: root_reference("math"),
|
||||||
};
|
};
|
||||||
let value = FetchInterfaceResponse {
|
let value = FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor("future-format/2"),
|
descriptor: descriptor("future-format/2"),
|
||||||
validator: Some(b"v2".to_vec()),
|
validator: Some(b"v2".to_vec()),
|
||||||
};
|
};
|
||||||
@@ -216,16 +226,19 @@ fn malformed_call_success_after_dispatch_is_outcome_unknown_and_stales_object()
|
|||||||
.unwrap(),
|
.unwrap(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let interface = client.prepare_interface(&session, "math").unwrap();
|
let interface = client
|
||||||
|
.prepare_interface(&session, root_reference("math"))
|
||||||
|
.unwrap();
|
||||||
client
|
client
|
||||||
.complete(
|
.complete(
|
||||||
interface.id,
|
interface.id,
|
||||||
encode_fetch_interface_response(
|
encode_fetch_interface_response(
|
||||||
&FetchInterfaceRequest {
|
&FetchInterfaceRequest {
|
||||||
interface: "math".into(),
|
interface: root_reference("math"),
|
||||||
},
|
},
|
||||||
&FetchInterfaceResponse {
|
&FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(INTERFACE_FORMAT_V1),
|
descriptor: descriptor(INTERFACE_FORMAT_V1),
|
||||||
validator: None,
|
validator: None,
|
||||||
},
|
},
|
||||||
@@ -238,7 +251,7 @@ fn malformed_call_success_after_dispatch_is_outcome_unknown_and_stales_object()
|
|||||||
.prepare_call(
|
.prepare_call(
|
||||||
&session,
|
&session,
|
||||||
"/item",
|
"/item",
|
||||||
"math",
|
&root_reference("math"),
|
||||||
"calculate",
|
"calculate",
|
||||||
BTreeMap::from([("value".into(), Value::Integer(2))]),
|
BTreeMap::from([("value".into(), Value::Integer(2))]),
|
||||||
)
|
)
|
||||||
@@ -293,16 +306,19 @@ fn plain_413_after_call_dispatch_is_unknown_transport_outcome() {
|
|||||||
.unwrap(),
|
.unwrap(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let interface = client.prepare_interface(&session, "math").unwrap();
|
let interface = client
|
||||||
|
.prepare_interface(&session, root_reference("math"))
|
||||||
|
.unwrap();
|
||||||
client
|
client
|
||||||
.complete(
|
.complete(
|
||||||
interface.id,
|
interface.id,
|
||||||
encode_fetch_interface_response(
|
encode_fetch_interface_response(
|
||||||
&FetchInterfaceRequest {
|
&FetchInterfaceRequest {
|
||||||
interface: "math".into(),
|
interface: root_reference("math"),
|
||||||
},
|
},
|
||||||
&FetchInterfaceResponse {
|
&FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(INTERFACE_FORMAT_V1),
|
descriptor: descriptor(INTERFACE_FORMAT_V1),
|
||||||
validator: None,
|
validator: None,
|
||||||
},
|
},
|
||||||
@@ -315,7 +331,7 @@ fn plain_413_after_call_dispatch_is_unknown_transport_outcome() {
|
|||||||
.prepare_call(
|
.prepare_call(
|
||||||
&session,
|
&session,
|
||||||
"/item",
|
"/item",
|
||||||
"math",
|
&root_reference("math"),
|
||||||
"calculate",
|
"calculate",
|
||||||
BTreeMap::from([("value".into(), Value::Integer(2))]),
|
BTreeMap::from([("value".into(), Value::Integer(2))]),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||||
|
wip_protocol::InterfaceReference {
|
||||||
|
scope: "/".into(),
|
||||||
|
name: name.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use wip_client::{
|
use wip_client::{
|
||||||
@@ -65,7 +72,7 @@ fn object(name: &str, reference: Option<&str>, validator: &[u8]) -> Object {
|
|||||||
Object {
|
Object {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
description: Some(format!("object {name}")),
|
description: Some(format!("object {name}")),
|
||||||
interfaces: vec!["math".into()],
|
interfaces: vec![root_reference("math")],
|
||||||
r#ref: reference.map(str::to_owned),
|
r#ref: reference.map(str::to_owned),
|
||||||
validator: Some(validator.to_vec()),
|
validator: Some(validator.to_vec()),
|
||||||
}
|
}
|
||||||
@@ -296,12 +303,18 @@ fn ensure_interface_deduplicates_loading_fresh_and_retained_failure() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let request = client
|
let request = client
|
||||||
.ensure_interface(&session, "math")
|
.ensure_interface(&session, root_reference("math"))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.expect("missing descriptor starts a fetch");
|
.expect("missing descriptor starts a fetch");
|
||||||
assert!(client.ensure_interface(&session, "math").unwrap().is_none());
|
assert!(
|
||||||
|
client
|
||||||
|
.ensure_interface(&session, root_reference("math"))
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
let value = FetchInterfaceResponse {
|
let value = FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(TypeExpr::Integer),
|
descriptor: descriptor(TypeExpr::Integer),
|
||||||
validator: Some(b"math-v1".to_vec()),
|
validator: Some(b"math-v1".to_vec()),
|
||||||
};
|
};
|
||||||
@@ -310,7 +323,7 @@ fn ensure_interface_deduplicates_loading_fresh_and_retained_failure() {
|
|||||||
request.id,
|
request.id,
|
||||||
encode_fetch_interface_response(
|
encode_fetch_interface_response(
|
||||||
&FetchInterfaceRequest {
|
&FetchInterfaceRequest {
|
||||||
interface: "math".into(),
|
interface: root_reference("math"),
|
||||||
},
|
},
|
||||||
&value,
|
&value,
|
||||||
wire_limits(),
|
wire_limits(),
|
||||||
@@ -318,10 +331,15 @@ fn ensure_interface_deduplicates_loading_fresh_and_retained_failure() {
|
|||||||
.unwrap(),
|
.unwrap(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(client.ensure_interface(&session, "math").unwrap().is_none());
|
assert!(
|
||||||
|
client
|
||||||
|
.ensure_interface(&session, root_reference("math"))
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
|
||||||
let failed = client
|
let failed = client
|
||||||
.ensure_interface(&session, "broken")
|
.ensure_interface(&session, root_reference("broken"))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.expect("new descriptor starts a fetch");
|
.expect("new descriptor starts a fetch");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -333,7 +351,7 @@ fn ensure_interface_deduplicates_loading_fresh_and_retained_failure() {
|
|||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
client
|
client
|
||||||
.ensure_interface(&session, "broken")
|
.ensure_interface(&session, root_reference("broken"))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.is_none(),
|
.is_none(),
|
||||||
"retained interface failures require an explicit refresh"
|
"retained interface failures require an explicit refresh"
|
||||||
@@ -348,7 +366,10 @@ fn ensure_interface_waits_for_request_and_cache_capacity_without_evicting() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let pending_object = client.refresh_object(&session, "/busy").unwrap();
|
let pending_object = client.refresh_object(&session, "/busy").unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
client.ensure_interface(&session, "math").unwrap().is_none(),
|
client
|
||||||
|
.ensure_interface(&session, root_reference("math"))
|
||||||
|
.unwrap()
|
||||||
|
.is_none(),
|
||||||
"lookahead waits while the in-flight bound is full"
|
"lookahead waits while the in-flight bound is full"
|
||||||
);
|
);
|
||||||
client
|
client
|
||||||
@@ -359,11 +380,12 @@ fn ensure_interface_waits_for_request_and_cache_capacity_without_evicting() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let request = client
|
let request = client
|
||||||
.ensure_interface(&session, "math")
|
.ensure_interface(&session, root_reference("math"))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.expect("lookahead resumes after capacity becomes available");
|
.expect("lookahead resumes after capacity becomes available");
|
||||||
let value = FetchInterfaceResponse {
|
let value = FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(TypeExpr::Integer),
|
descriptor: descriptor(TypeExpr::Integer),
|
||||||
validator: None,
|
validator: None,
|
||||||
};
|
};
|
||||||
@@ -372,7 +394,7 @@ fn ensure_interface_waits_for_request_and_cache_capacity_without_evicting() {
|
|||||||
request.id,
|
request.id,
|
||||||
encode_fetch_interface_response(
|
encode_fetch_interface_response(
|
||||||
&FetchInterfaceRequest {
|
&FetchInterfaceRequest {
|
||||||
interface: "math".into(),
|
interface: root_reference("math"),
|
||||||
},
|
},
|
||||||
&value,
|
&value,
|
||||||
wire_limits(),
|
wire_limits(),
|
||||||
@@ -383,18 +405,32 @@ fn ensure_interface_waits_for_request_and_cache_capacity_without_evicting() {
|
|||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
client
|
client
|
||||||
.ensure_interface(&session, "other")
|
.ensure_interface(&session, root_reference("other"))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.is_none(),
|
.is_none(),
|
||||||
"lookahead does not churn a full descriptor cache"
|
"lookahead does not churn a full descriptor cache"
|
||||||
);
|
);
|
||||||
assert!(client.interface(&session, "math").is_some());
|
assert!(
|
||||||
assert!(client.interface(&session, "other").is_none());
|
client
|
||||||
|
.interface(&session, &root_reference("math"))
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
client
|
||||||
|
.interface(&session, &root_reference("other"))
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
|
||||||
let explicit = client.prepare_interface(&session, "other").unwrap();
|
let explicit = client
|
||||||
assert!(client.interface(&session, "math").is_none());
|
.prepare_interface(&session, root_reference("other"))
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
client
|
||||||
|
.interface(&session, &root_reference("math"))
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
client.interface(&session, "other").unwrap().state,
|
client.interface(&session, &root_reference("other")).unwrap().state,
|
||||||
ObservationState::Loading(id) if id == explicit.id
|
ObservationState::Loading(id) if id == explicit.id
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -408,7 +444,9 @@ fn independent_retrievals_run_concurrently_and_complete_out_of_order() {
|
|||||||
|
|
||||||
let first = client.refresh_observed(&session, "/one", 0).unwrap();
|
let first = client.refresh_observed(&session, "/one", 0).unwrap();
|
||||||
let second = client.refresh_observed(&session, "/two", 0).unwrap();
|
let second = client.refresh_observed(&session, "/two", 0).unwrap();
|
||||||
let interface = client.prepare_interface(&session, "math").unwrap();
|
let interface = client
|
||||||
|
.prepare_interface(&session, root_reference("math"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(first.request.uri().path(), "/wip/v1/observe");
|
assert_eq!(first.request.uri().path(), "/wip/v1/observe");
|
||||||
assert_eq!(second.request.uri().path(), "/wip/v1/observe");
|
assert_eq!(second.request.uri().path(), "/wip/v1/observe");
|
||||||
@@ -426,10 +464,11 @@ fn independent_retrievals_run_concurrently_and_complete_out_of_order() {
|
|||||||
interface.id,
|
interface.id,
|
||||||
encode_fetch_interface_response(
|
encode_fetch_interface_response(
|
||||||
&FetchInterfaceRequest {
|
&FetchInterfaceRequest {
|
||||||
interface: "math".into(),
|
interface: root_reference("math"),
|
||||||
},
|
},
|
||||||
&FetchInterfaceResponse {
|
&FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(TypeExpr::Integer),
|
descriptor: descriptor(TypeExpr::Integer),
|
||||||
validator: Some(b"math-v1".to_vec()),
|
validator: Some(b"math-v1".to_vec()),
|
||||||
},
|
},
|
||||||
@@ -454,7 +493,10 @@ fn independent_retrievals_run_concurrently_and_complete_out_of_order() {
|
|||||||
ObservationState::Fresh
|
ObservationState::Fresh
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
client.interface(&session, "math").unwrap().state,
|
client
|
||||||
|
.interface(&session, &root_reference("math"))
|
||||||
|
.unwrap()
|
||||||
|
.state,
|
||||||
ObservationState::Fresh
|
ObservationState::Fresh
|
||||||
);
|
);
|
||||||
assert_eq!(client.in_flight_len(), 0);
|
assert_eq!(client.in_flight_len(), 0);
|
||||||
@@ -941,14 +983,21 @@ fn call_context_fixes_membership_validators_descriptor_and_result_validation() {
|
|||||||
&mut client,
|
&mut client,
|
||||||
&session,
|
&session,
|
||||||
FetchInterfaceResponse {
|
FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(TypeExpr::Integer),
|
descriptor: descriptor(TypeExpr::Integer),
|
||||||
validator: Some(b"interface-v1".to_vec()),
|
validator: Some(b"interface-v1".to_vec()),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let call = client
|
let call = client
|
||||||
.prepare_call(&session, "/item", "math", "calculate", args(7))
|
.prepare_call(
|
||||||
|
&session,
|
||||||
|
"/item",
|
||||||
|
&root_reference("math"),
|
||||||
|
"calculate",
|
||||||
|
args(7),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
client
|
client
|
||||||
@@ -965,7 +1014,8 @@ fn call_context_fixes_membership_validators_descriptor_and_result_validation() {
|
|||||||
&mut client,
|
&mut client,
|
||||||
&session,
|
&session,
|
||||||
FetchInterfaceResponse {
|
FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(TypeExpr::String),
|
descriptor: descriptor(TypeExpr::String),
|
||||||
validator: Some(b"interface-v2".to_vec()),
|
validator: Some(b"interface-v2".to_vec()),
|
||||||
},
|
},
|
||||||
@@ -981,7 +1031,8 @@ fn call_context_fixes_membership_validators_descriptor_and_result_validation() {
|
|||||||
validator: Some(b"object-v1".to_vec()),
|
validator: Some(b"object-v1".to_vec()),
|
||||||
},
|
},
|
||||||
interface: wip_protocol::InterfaceTarget {
|
interface: wip_protocol::InterfaceTarget {
|
||||||
reference: "math".into(),
|
scope_ref: None,
|
||||||
|
reference: root_reference("math"),
|
||||||
validator: Some(b"interface-v1".to_vec()),
|
validator: Some(b"interface-v1".to_vec()),
|
||||||
},
|
},
|
||||||
operation: "calculate".into(),
|
operation: "calculate".into(),
|
||||||
@@ -1015,7 +1066,10 @@ fn call_context_fixes_membership_validators_descriptor_and_result_validation() {
|
|||||||
);
|
);
|
||||||
assert!(matches!(history_context.outcome, CallOutcome::Success(_)));
|
assert!(matches!(history_context.outcome, CallOutcome::Success(_)));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
client.interface(&session, "math").unwrap().descriptor,
|
client
|
||||||
|
.interface(&session, &root_reference("math"))
|
||||||
|
.unwrap()
|
||||||
|
.descriptor,
|
||||||
Some(descriptor(TypeExpr::String))
|
Some(descriptor(TypeExpr::String))
|
||||||
);
|
);
|
||||||
let observation = client.object(&session, "/item").unwrap();
|
let observation = client.object(&session, "/item").unwrap();
|
||||||
@@ -1039,14 +1093,21 @@ fn successful_call_without_validator_preserves_observed_validator() {
|
|||||||
&mut client,
|
&mut client,
|
||||||
&session,
|
&session,
|
||||||
FetchInterfaceResponse {
|
FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(TypeExpr::Integer),
|
descriptor: descriptor(TypeExpr::Integer),
|
||||||
validator: None,
|
validator: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let call = client
|
let call = client
|
||||||
.prepare_call(&session, "/item", "math", "calculate", args(1))
|
.prepare_call(
|
||||||
|
&session,
|
||||||
|
"/item",
|
||||||
|
&root_reference("math"),
|
||||||
|
"calculate",
|
||||||
|
args(1),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let request = client
|
let request = client
|
||||||
.pending_call_context(call.id)
|
.pending_call_context(call.id)
|
||||||
@@ -1080,7 +1141,13 @@ fn successful_call_without_validator_preserves_observed_validator() {
|
|||||||
assert_eq!(observation.validator, Some(b"object-v1".to_vec()));
|
assert_eq!(observation.validator, Some(b"object-v1".to_vec()));
|
||||||
|
|
||||||
let next = client
|
let next = client
|
||||||
.prepare_call(&session, "/item", "math", "calculate", args(2))
|
.prepare_call(
|
||||||
|
&session,
|
||||||
|
"/item",
|
||||||
|
&root_reference("math"),
|
||||||
|
"calculate",
|
||||||
|
args(2),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
client
|
client
|
||||||
@@ -1109,14 +1176,21 @@ fn newer_observation_prevents_older_success_validator_overwrite() {
|
|||||||
&mut client,
|
&mut client,
|
||||||
&session,
|
&session,
|
||||||
FetchInterfaceResponse {
|
FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(TypeExpr::Integer),
|
descriptor: descriptor(TypeExpr::Integer),
|
||||||
validator: None,
|
validator: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let call = client
|
let call = client
|
||||||
.prepare_call(&session, "/item", "math", "calculate", args(1))
|
.prepare_call(
|
||||||
|
&session,
|
||||||
|
"/item",
|
||||||
|
&root_reference("math"),
|
||||||
|
"calculate",
|
||||||
|
args(1),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let request = client
|
let request = client
|
||||||
.pending_call_context(call.id)
|
.pending_call_context(call.id)
|
||||||
@@ -1173,7 +1247,8 @@ fn older_retrieval_cannot_overwrite_success_validator() {
|
|||||||
&mut client,
|
&mut client,
|
||||||
&session,
|
&session,
|
||||||
FetchInterfaceResponse {
|
FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(TypeExpr::Integer),
|
descriptor: descriptor(TypeExpr::Integer),
|
||||||
validator: Some(b"interface-v1".to_vec()),
|
validator: Some(b"interface-v1".to_vec()),
|
||||||
},
|
},
|
||||||
@@ -1181,7 +1256,13 @@ fn older_retrieval_cannot_overwrite_success_validator() {
|
|||||||
|
|
||||||
let older_alias = client.refresh_observed(&session, "/alias/item", 0).unwrap();
|
let older_alias = client.refresh_observed(&session, "/alias/item", 0).unwrap();
|
||||||
let call = client
|
let call = client
|
||||||
.prepare_call(&session, "/item", "math", "calculate", args(7))
|
.prepare_call(
|
||||||
|
&session,
|
||||||
|
"/item",
|
||||||
|
&root_reference("math"),
|
||||||
|
"calculate",
|
||||||
|
args(7),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let request = client
|
let request = client
|
||||||
.pending_call_context(call.id)
|
.pending_call_context(call.id)
|
||||||
@@ -1227,7 +1308,13 @@ fn older_retrieval_cannot_overwrite_success_validator() {
|
|||||||
assert_eq!(alias.validator, Some(b"object-v2".to_vec()));
|
assert_eq!(alias.validator, Some(b"object-v2".to_vec()));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
client
|
client
|
||||||
.prepare_call(&session, "/alias/item", "math", "calculate", args(1))
|
.prepare_call(
|
||||||
|
&session,
|
||||||
|
"/alias/item",
|
||||||
|
&root_reference("math"),
|
||||||
|
"calculate",
|
||||||
|
args(1)
|
||||||
|
)
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
.kind,
|
.kind,
|
||||||
ClientErrorKind::MissingObservation
|
ClientErrorKind::MissingObservation
|
||||||
@@ -1243,7 +1330,13 @@ fn older_retrieval_cannot_overwrite_success_validator() {
|
|||||||
.refresh_observed(&session, "/second/item", 0)
|
.refresh_observed(&session, "/second/item", 0)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let call = client
|
let call = client
|
||||||
.prepare_call(&session, "/item", "math", "calculate", args(8))
|
.prepare_call(
|
||||||
|
&session,
|
||||||
|
"/item",
|
||||||
|
&root_reference("math"),
|
||||||
|
"calculate",
|
||||||
|
args(8),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
client.mark_dispatched(call.id).unwrap();
|
client.mark_dispatched(call.id).unwrap();
|
||||||
let unknown = ProtocolError {
|
let unknown = ProtocolError {
|
||||||
@@ -1292,13 +1385,20 @@ fn in_flight_calls_pin_target_identity_until_staleness_is_applied() {
|
|||||||
&mut client,
|
&mut client,
|
||||||
&session,
|
&session,
|
||||||
FetchInterfaceResponse {
|
FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(TypeExpr::Integer),
|
descriptor: descriptor(TypeExpr::Integer),
|
||||||
validator: Some(b"interface-v1".to_vec()),
|
validator: Some(b"interface-v1".to_vec()),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let call = client
|
let call = client
|
||||||
.prepare_call(&session, "/item", "math", "calculate", args(7))
|
.prepare_call(
|
||||||
|
&session,
|
||||||
|
"/item",
|
||||||
|
&root_reference("math"),
|
||||||
|
"calculate",
|
||||||
|
args(7),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
client.mark_dispatched(call.id).unwrap();
|
client.mark_dispatched(call.id).unwrap();
|
||||||
|
|
||||||
@@ -1350,13 +1450,20 @@ fn validator_mismatch_stales_only_its_observation_and_never_retries() {
|
|||||||
&mut client,
|
&mut client,
|
||||||
&session,
|
&session,
|
||||||
FetchInterfaceResponse {
|
FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(TypeExpr::Integer),
|
descriptor: descriptor(TypeExpr::Integer),
|
||||||
validator: Some(b"i1".to_vec()),
|
validator: Some(b"i1".to_vec()),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let call = client
|
let call = client
|
||||||
.prepare_call(&session, "/item", "math", "calculate", args(1))
|
.prepare_call(
|
||||||
|
&session,
|
||||||
|
"/item",
|
||||||
|
&root_reference("math"),
|
||||||
|
"calculate",
|
||||||
|
args(1),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
client.mark_dispatched(call.id).unwrap();
|
client.mark_dispatched(call.id).unwrap();
|
||||||
let error = ProtocolError {
|
let error = ProtocolError {
|
||||||
@@ -1369,7 +1476,10 @@ fn validator_mismatch_stales_only_its_observation_and_never_retries() {
|
|||||||
client.complete(call.id, response).unwrap();
|
client.complete(call.id, response).unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
client.interface(&session, "math").unwrap().state,
|
client
|
||||||
|
.interface(&session, &root_reference("math"))
|
||||||
|
.unwrap()
|
||||||
|
.state,
|
||||||
ObservationState::Stale
|
ObservationState::Stale
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1384,7 +1494,8 @@ fn validator_mismatch_stales_only_its_observation_and_never_retries() {
|
|||||||
&mut client,
|
&mut client,
|
||||||
&session,
|
&session,
|
||||||
FetchInterfaceResponse {
|
FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(TypeExpr::Integer),
|
descriptor: descriptor(TypeExpr::Integer),
|
||||||
validator: Some(b"i2".to_vec()),
|
validator: Some(b"i2".to_vec()),
|
||||||
},
|
},
|
||||||
@@ -1404,14 +1515,21 @@ fn protocol_and_transport_unknown_outcomes_stale_without_retry() {
|
|||||||
&mut client,
|
&mut client,
|
||||||
&session,
|
&session,
|
||||||
FetchInterfaceResponse {
|
FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(TypeExpr::Integer),
|
descriptor: descriptor(TypeExpr::Integer),
|
||||||
validator: None,
|
validator: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let call = client
|
let call = client
|
||||||
.prepare_call(&session, "/item", "math", "calculate", args(1))
|
.prepare_call(
|
||||||
|
&session,
|
||||||
|
"/item",
|
||||||
|
&root_reference("math"),
|
||||||
|
"calculate",
|
||||||
|
args(1),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
client.mark_dispatched(call.id).unwrap();
|
client.mark_dispatched(call.id).unwrap();
|
||||||
let unknown = ProtocolError {
|
let unknown = ProtocolError {
|
||||||
@@ -1447,7 +1565,13 @@ fn protocol_and_transport_unknown_outcomes_stale_without_retry() {
|
|||||||
|
|
||||||
observe_object(&mut client, &session, "/item", object("item", None, b"v2"));
|
observe_object(&mut client, &session, "/item", object("item", None, b"v2"));
|
||||||
let second = client
|
let second = client
|
||||||
.prepare_call(&session, "/item", "math", "calculate", args(2))
|
.prepare_call(
|
||||||
|
&session,
|
||||||
|
"/item",
|
||||||
|
&root_reference("math"),
|
||||||
|
"calculate",
|
||||||
|
args(2),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
client.mark_dispatched(second.id).unwrap();
|
client.mark_dispatched(second.id).unwrap();
|
||||||
let completion = client
|
let completion = client
|
||||||
@@ -1695,8 +1819,12 @@ fn superseded_transport_failure_does_not_poison_newer_refresh() {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let old = client.prepare_interface(&session, "math").unwrap();
|
let old = client
|
||||||
let newer = client.prepare_interface(&session, "math").unwrap();
|
.prepare_interface(&session, root_reference("math"))
|
||||||
|
.unwrap();
|
||||||
|
let newer = client
|
||||||
|
.prepare_interface(&session, root_reference("math"))
|
||||||
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
client
|
client
|
||||||
.fail_transport(old.id, "old connection failed")
|
.fail_transport(old.id, "old connection failed")
|
||||||
@@ -1704,11 +1832,12 @@ fn superseded_transport_failure_does_not_poison_newer_refresh() {
|
|||||||
Completion::StaleResponseRejected(old.id)
|
Completion::StaleResponseRejected(old.id)
|
||||||
);
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
client.interface(&session, "math").unwrap().state,
|
client.interface(&session, &root_reference("math")).unwrap().state,
|
||||||
ObservationState::Loading(id) if id == newer.id
|
ObservationState::Loading(id) if id == newer.id
|
||||||
));
|
));
|
||||||
let value = FetchInterfaceResponse {
|
let value = FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(TypeExpr::Integer),
|
descriptor: descriptor(TypeExpr::Integer),
|
||||||
validator: Some(b"current".to_vec()),
|
validator: Some(b"current".to_vec()),
|
||||||
};
|
};
|
||||||
@@ -1717,7 +1846,7 @@ fn superseded_transport_failure_does_not_poison_newer_refresh() {
|
|||||||
newer.id,
|
newer.id,
|
||||||
encode_fetch_interface_response(
|
encode_fetch_interface_response(
|
||||||
&FetchInterfaceRequest {
|
&FetchInterfaceRequest {
|
||||||
interface: "math".into(),
|
interface: root_reference("math"),
|
||||||
},
|
},
|
||||||
&value,
|
&value,
|
||||||
wire_limits(),
|
wire_limits(),
|
||||||
@@ -1737,15 +1866,19 @@ fn validator_free_interface_observation_remains_immutable() {
|
|||||||
&mut client,
|
&mut client,
|
||||||
&session,
|
&session,
|
||||||
FetchInterfaceResponse {
|
FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(TypeExpr::Integer),
|
descriptor: descriptor(TypeExpr::Integer),
|
||||||
validator: None,
|
validator: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let refresh = client.prepare_interface(&session, "math").unwrap();
|
let refresh = client
|
||||||
|
.prepare_interface(&session, root_reference("math"))
|
||||||
|
.unwrap();
|
||||||
let contradictory = FetchInterfaceResponse {
|
let contradictory = FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(TypeExpr::Integer),
|
descriptor: descriptor(TypeExpr::Integer),
|
||||||
validator: Some(b"became-mutable".to_vec()),
|
validator: Some(b"became-mutable".to_vec()),
|
||||||
};
|
};
|
||||||
@@ -1754,7 +1887,7 @@ fn validator_free_interface_observation_remains_immutable() {
|
|||||||
refresh.id,
|
refresh.id,
|
||||||
encode_fetch_interface_response(
|
encode_fetch_interface_response(
|
||||||
&FetchInterfaceRequest {
|
&FetchInterfaceRequest {
|
||||||
interface: "math".into(),
|
interface: root_reference("math"),
|
||||||
},
|
},
|
||||||
&contradictory,
|
&contradictory,
|
||||||
wire_limits(),
|
wire_limits(),
|
||||||
@@ -1763,7 +1896,13 @@ fn validator_free_interface_observation_remains_immutable() {
|
|||||||
)
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert_eq!(error.kind, ClientErrorKind::InvalidResponse);
|
assert_eq!(error.kind, ClientErrorKind::InvalidResponse);
|
||||||
assert_eq!(client.interface(&session, "math").unwrap().validator, None);
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.interface(&session, &root_reference("math"))
|
||||||
|
.unwrap()
|
||||||
|
.validator,
|
||||||
|
None
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1876,3 +2015,64 @@ fn in_flight_and_history_are_bounded() {
|
|||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert_eq!(client.diagnostics(&session).unwrap().len(), 1);
|
assert_eq!(client.diagnostics(&session).unwrap().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_interface_keys_membership_and_scope_ref_are_preserved() {
|
||||||
|
let mut client = client(8, 8, 8, 8);
|
||||||
|
let session = client
|
||||||
|
.open_session("https://example.test/wip", SecurityContext::new("subject"))
|
||||||
|
.unwrap();
|
||||||
|
let root = root_reference("same#::操作");
|
||||||
|
let local = wip_protocol::InterfaceReference {
|
||||||
|
scope: "/item".into(),
|
||||||
|
name: root.name.clone(),
|
||||||
|
};
|
||||||
|
let mut value = object("item", None, b"object-v1");
|
||||||
|
value.interfaces = vec![local.clone()];
|
||||||
|
observe_object(&mut client, &session, "/item", value);
|
||||||
|
for (reference, scope_ref) in [
|
||||||
|
(root.clone(), None),
|
||||||
|
(local.clone(), Some("scope-identity".into())),
|
||||||
|
] {
|
||||||
|
observe_interface(
|
||||||
|
&mut client,
|
||||||
|
&session,
|
||||||
|
FetchInterfaceResponse {
|
||||||
|
interface: reference,
|
||||||
|
descriptor: descriptor(TypeExpr::Integer),
|
||||||
|
scope_ref,
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(client.interface(&session, &root).is_some());
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.interface(&session, &local)
|
||||||
|
.unwrap()
|
||||||
|
.scope_ref
|
||||||
|
.as_deref(),
|
||||||
|
Some("scope-identity")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.prepare_call(&session, "/item", &root, "calculate", args(1))
|
||||||
|
.unwrap_err()
|
||||||
|
.kind,
|
||||||
|
ClientErrorKind::InvalidRequest
|
||||||
|
);
|
||||||
|
let call = client
|
||||||
|
.prepare_call(&session, "/item", &local, "calculate", args(1))
|
||||||
|
.unwrap();
|
||||||
|
let context = client.pending_call_context(call.id).unwrap();
|
||||||
|
assert_eq!(context.request().interface.reference, local);
|
||||||
|
assert_eq!(
|
||||||
|
context.request().interface.scope_ref.as_deref(),
|
||||||
|
Some("scope-identity")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
context.interface().scope_ref.as_deref(),
|
||||||
|
Some("scope-identity")
|
||||||
|
);
|
||||||
|
assert_eq!(client.interface(&session, &root).unwrap().scope_ref, None);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wip-fs-server"
|
name = "wip-fs-server"
|
||||||
version = "0.1.0"
|
version.workspace = true
|
||||||
edition = "2024"
|
edition.workspace = true
|
||||||
description = "Request-time filesystem projection server for WIP over HTTP"
|
rust-version.workspace = true
|
||||||
license = "MIT OR Apache-2.0"
|
description = "Extensible request-time filesystem projection library and WIP over HTTP server"
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
http = "1"
|
http = "1"
|
||||||
rustix = { version = "1.1", features = ["fs"] }
|
rustix = { version = "1.1", features = ["fs"] }
|
||||||
wip-host = { path = "../wip-host" }
|
wip-host.workspace = true
|
||||||
wip-http = { path = "../wip-http" }
|
wip-http.workspace = true
|
||||||
wip-interface = { path = "../wip-interface" }
|
wip-interface.workspace = true
|
||||||
wip-protocol = { path = "../wip-protocol" }
|
wip-protocol.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
@@ -6,6 +6,119 @@ keep one object instance per entry. It uses `wip-host` for publication and typed
|
|||||||
operation dispatch and uses the standard `wip-http` routes, JSON codecs,
|
operation dispatch and uses the standard `wip-http` routes, JSON codecs,
|
||||||
envelopes, and HTTP status mapping.
|
envelopes, and HTTP status mapping.
|
||||||
|
|
||||||
|
## Library assembly and migration
|
||||||
|
|
||||||
|
The existing `ServerConfig`, `WipFsServer::new`, `handle_http`, CLI flags and wire
|
||||||
|
representation remain compatible. The formerly private projection has become a
|
||||||
|
public, generic library engine. No package version or publication policy changes
|
||||||
|
are required.
|
||||||
|
|
||||||
|
Use `default_projection(root, access, limits)` to obtain the default Linux engine
|
||||||
|
without starting a listener. `projection.view(&principal)` is the effective view;
|
||||||
|
it owns canonical `observe` and `fetch_interface` and implements the standard
|
||||||
|
`wip_host::ObjectProvider` typed `call_operation` API. `DefaultPolicy` has the unit
|
||||||
|
principal `()`; default engines also offer unit-principal convenience methods.
|
||||||
|
|
||||||
|
For an external backend/composition:
|
||||||
|
|
||||||
|
1. Implement `BackendEntry` on a local, cloneable, root-confined stable capability.
|
||||||
|
Its `WipObject` metadata is public-only; its validator binds identity and the
|
||||||
|
protected state. Implement `FilesystemBackend` to resolve one connected
|
||||||
|
`ResolvedEntry<Entry>` (root-to-parent ancestry plus pinned target), enumerate
|
||||||
|
bounded children, and execute against that already-resolved lineage.
|
||||||
|
2. Register generated `#[wip_interface::interface]` definitions using the existing
|
||||||
|
`wip_host::InterfaceRegistry`. `DefaultInterfaces::register` optionally supplies
|
||||||
|
reusable built-in tokens; using those tokens does not force their publication.
|
||||||
|
3. Implement `Composition<Entry>` on a local type. Its local Object wraps the entry
|
||||||
|
and implements both `WipObject` and the chosen generated traits, avoiding orphan
|
||||||
|
rules. `PublishedObject::expose` proves each implementation at compile time.
|
||||||
|
Publication can add, replace, reorder or omit built-ins based on entry metadata
|
||||||
|
and actual backend capabilities, not just entry kind. `LinuxEntry` implements
|
||||||
|
bounded `FileRead`, `FileWrite` and `SymlinkInspect` for safe delegation; never
|
||||||
|
advertise write in read-only mode or link operations on a non-link capability.
|
||||||
|
4. Assemble `Projection::new(backend, registry, composition, policy, limits)`.
|
||||||
|
`ProjectionPolicy` supplies an opaque associated `Principal`, hides Objects and
|
||||||
|
entire hidden subtrees, selects an ordered subset of implemented interfaces,
|
||||||
|
controls descriptor/operation visibility, and permits or denies execution.
|
||||||
|
5. An authenticated deployment chooses the principal and `ExecutionContext`. To
|
||||||
|
use HTTP, construct `HttpAdapter::new(&view, &endpoint, limits)` and invoke
|
||||||
|
`handle_http_with_context`. This uses the standard `wip-http` binding, not a
|
||||||
|
filesystem wire profile. Enforce body collection, header, concurrency and
|
||||||
|
deadline bounds in the deployment's listener; the adapter owns none of these
|
||||||
|
intake resources. The bundled loopback listener remains a thin default user.
|
||||||
|
|
||||||
|
A complete external-style memory backend, local Object with multiple custom
|
||||||
|
Operations, typed registration, principal policy and HTTP flow is exercised in
|
||||||
|
[`tests/extensions.rs`](tests/extensions.rs). It uses no Linux filesystem for its
|
||||||
|
backend. The public library APIs do not require private types, a fork, an HTTP
|
||||||
|
listener, a global object registry, or dynamic plugin loading.
|
||||||
|
|
||||||
|
### Extension security contract
|
||||||
|
|
||||||
|
Backend implementations and Operation authors are trusted code, not sandboxed
|
||||||
|
plugins. `FilesystemBackend::resolve` returns `ResolvedEntry::new(ancestors, target)`
|
||||||
|
from one connected backend traversal/snapshot, not independent path-prefix
|
||||||
|
resolutions. A root target has no ancestors; other targets retain the root first,
|
||||||
|
then intermediate parent containers. This is backend-private context, never wire
|
||||||
|
metadata. Backend `resolve` and `execute` signatures both carry this type;
|
||||||
|
compositions still receive only the exact scoped target `Entry`.
|
||||||
|
|
||||||
|
`FilesystemBackend::execute` must validate ancestor membership/identity and
|
||||||
|
refresh **all** lineage state/validators inside its transaction or lock before
|
||||||
|
calling the engine callback, retaining the same target and connected ancestry
|
||||||
|
through validation and dispatch. Replaced/detached ancestors must return
|
||||||
|
`NotFound` without callback invocation, even when the replacement is visible.
|
||||||
|
The engine evaluates Object visibility for every refreshed ancestor and target
|
||||||
|
inside that same boundary; a hidden ancestor rejects the entire subtree before
|
||||||
|
publication/typed dispatch. Never fix a changed lineage by resolving the raw path
|
||||||
|
again and substituting capabilities. Participating policy-relevant backend state
|
||||||
|
changes must be serialized by this boundary, not just target-content writes.
|
||||||
|
Return the committed post-operation validator. Retained ancestry is subject to
|
||||||
|
handle/work ceilings; resource exhaustion is an error, not missing ancestry.
|
||||||
|
Confirm rollback only after rollback succeeds. Pre-dispatch failures
|
||||||
|
must never commit; failed started operations without a non-commit guarantee remain
|
||||||
|
`OperationOutcomeUnknown`. Object implementations must delegate validators to
|
||||||
|
that entry and declare requirements per `(InterfaceRef, operation)`, rather than
|
||||||
|
classify all operations by a read/write name. Operations use the scoped entry and
|
||||||
|
propagated context, not a fresh raw-path resolution; they must check deadlines,
|
||||||
|
cancellation and resource budgets throughout backend work and report effects
|
||||||
|
truthfully. POSIX symlinks, identity, atomic mutations and locking cannot be assumed
|
||||||
|
for other backends.
|
||||||
|
|
||||||
|
The default Linux backend acquires advisory exclusive locks in root-to-parent
|
||||||
|
order and then on the lockable target (directory or regular file). It compares
|
||||||
|
retained ancestor identity to no-follow descriptor-relative lookup results,
|
||||||
|
refreshes lineage snapshots after acquiring the locks, and holds the guards
|
||||||
|
through post-dispatch state observation. The configured root remains its pinned
|
||||||
|
capability even if its textual path is renamed; replaced intermediate ancestors
|
||||||
|
are rejected. Leaf path replacement never redirects execution to a new inode.
|
||||||
|
This serializes cooperating library calls; it does **not** promise transactional
|
||||||
|
isolation from unrelated processes ignoring `flock`. Linux capabilities only
|
||||||
|
expose bounded methods, not raw descriptors or backend paths.
|
||||||
|
|
||||||
|
A policy must be stable/deterministic within one effective Worldspace view.
|
||||||
|
Interface/operation visibility is uniform per registered reference within that
|
||||||
|
view, so observation, fetch and dispatch use the identical filtered descriptor.
|
||||||
|
For entry-specific operation sets, register distinct interface definitions and
|
||||||
|
select them by entry. Different principals may have different effective views.
|
||||||
|
Changing a policy must establish a new view/reference lifetime (e.g. rebuild the
|
||||||
|
registry/engine); do not keep validator-free references while changing their
|
||||||
|
meaning. Execution permission may deny a visible Operation without hiding it.
|
||||||
|
A published interface is always fetchable by that principal; hidden operations
|
||||||
|
are omitted (along with their unreachable named types and interface-wide docs)
|
||||||
|
and guessed calls return `OperationNotFound`, whereas visible denied
|
||||||
|
operations return `PermissionDenied`. Hidden Objects and descendants return
|
||||||
|
`NotFound` and are absent from parent observations. Work spent scanning hidden
|
||||||
|
entries still counts against traversal bounds; resource limits are not a
|
||||||
|
constant-time/side-channel guarantee.
|
||||||
|
|
||||||
|
Never put absolute paths, hidden metadata, credentials or policy internals in
|
||||||
|
public `WipObject` metadata, descriptions, validators or normal logs. Validators
|
||||||
|
must be opaque public tokens, not confidential content. Engine/HTTP failures use
|
||||||
|
bounded generic diagnostics; arbitrary trusted implementation metadata/logging
|
||||||
|
cannot be sanitized automatically. Do not log credentials or sensitive panic
|
||||||
|
payloads from extensions.
|
||||||
|
|
||||||
## Security model
|
## Security model
|
||||||
|
|
||||||
- The effective root is opened once as a directory capability. Root path
|
- The effective root is opened once as a directory capability. Root path
|
||||||
@@ -82,13 +195,15 @@ curl -sS -X POST http://127.0.0.1:8080/wip/v1/observe \
|
|||||||
--data '{"path":"/","depth":2}'
|
--data '{"path":"/","depth":2}'
|
||||||
```
|
```
|
||||||
|
|
||||||
A regular-file observation returns the opaque read interface reference and an object
|
A regular-file observation returns a structured read interface reference and an
|
||||||
validator. Retrieve that descriptor with:
|
object validator. Built-in legacy registrations use scope `/` and copy the opaque
|
||||||
|
registration string verbatim into `name`; they do not emit `scope_ref`. Copy the
|
||||||
|
complete reference from the observation to retrieve that descriptor:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
curl -sS -X POST http://127.0.0.1:8080/wip/v1/fetch_interface \
|
curl -sS -X POST http://127.0.0.1:8080/wip/v1/fetch_interface \
|
||||||
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||||
--data '{"interface":"<read-interface-from-observation>"}'
|
--data '{"interface":{"scope":"/","name":"<read-interface-name-from-observation>"}}'
|
||||||
```
|
```
|
||||||
|
|
||||||
Then call a bounded range read:
|
Then call a bounded range read:
|
||||||
@@ -98,7 +213,7 @@ curl -sS -X POST http://127.0.0.1:8080/wip/v1/call_operation \
|
|||||||
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||||
--data '{
|
--data '{
|
||||||
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
|
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
|
||||||
"interface":{"reference":"<read-interface>"},
|
"interface":{"reference":{"scope":"/","name":"<read-interface-name>"}},
|
||||||
"operation":"read",
|
"operation":"read",
|
||||||
"arguments":{"offset":0,"length":4096}
|
"arguments":{"offset":0,"length":4096}
|
||||||
}'
|
}'
|
||||||
@@ -112,7 +227,7 @@ curl -sS -X POST http://127.0.0.1:8080/wip/v1/call_operation \
|
|||||||
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||||
--data '{
|
--data '{
|
||||||
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
|
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
|
||||||
"interface":{"reference":"<write-interface>"},
|
"interface":{"reference":{"scope":"/","name":"<write-interface-name>"}},
|
||||||
"operation":"replace",
|
"operation":"replace",
|
||||||
"arguments":{"contents":"dXBkYXRlZA=="}
|
"arguments":{"contents":"dXBkYXRlZA=="}
|
||||||
}'
|
}'
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
//! Reusable default Linux composition; no listener or authentication dependency.
|
||||||
|
use crate::engine::{BackendEntry, Composition, DefaultPolicy, EntryKind, Projection};
|
||||||
|
use crate::interfaces::{
|
||||||
|
FileRead, FileReadDefinition, FileWrite, FileWriteDefinition, SymlinkInspect,
|
||||||
|
SymlinkInspectDefinition,
|
||||||
|
};
|
||||||
|
use crate::projection::{LinuxBackend, LinuxEntry};
|
||||||
|
use crate::{AccessMode, ResourceLimits};
|
||||||
|
use wip_host::{
|
||||||
|
ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, PublishedObject,
|
||||||
|
RegisteredInterface, RegistrationError, Validator, WipObject,
|
||||||
|
};
|
||||||
|
use wip_interface as wip;
|
||||||
|
|
||||||
|
/// Registered built-in interfaces, usable by external compositions and Objects.
|
||||||
|
pub struct DefaultInterfaces {
|
||||||
|
/// Bounded regular-file reads.
|
||||||
|
pub read: RegisteredInterface<FileReadDefinition>,
|
||||||
|
/// No-follow link inspection.
|
||||||
|
pub symlink: RegisteredInterface<SymlinkInspectDefinition>,
|
||||||
|
/// Validator-protected replacement.
|
||||||
|
pub write: RegisteredInterface<FileWriteDefinition>,
|
||||||
|
}
|
||||||
|
impl DefaultInterfaces {
|
||||||
|
/// Register reusable default descriptors without publishing any objects.
|
||||||
|
pub fn register(registry: &mut InterfaceRegistry) -> Result<Self, RegistrationError> {
|
||||||
|
Ok(Self {
|
||||||
|
read: registry.register(FileReadDefinition, None)?,
|
||||||
|
symlink: registry.register(SymlinkInspectDefinition, None)?,
|
||||||
|
write: registry.register(FileWriteDefinition, None)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default capability-aware Linux publication, independent of HTTP.
|
||||||
|
pub struct DefaultComposition {
|
||||||
|
interfaces: DefaultInterfaces,
|
||||||
|
}
|
||||||
|
impl DefaultComposition {
|
||||||
|
/// Use previously registered built-in interface tokens.
|
||||||
|
pub fn new(interfaces: DefaultInterfaces) -> Self {
|
||||||
|
Self { interfaces }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request-scoped default Object; external Objects may instead wrap `LinuxEntry`.
|
||||||
|
pub struct DefaultObject {
|
||||||
|
entry: LinuxEntry,
|
||||||
|
write: InterfaceRef,
|
||||||
|
}
|
||||||
|
impl DefaultObject {
|
||||||
|
/// The pinned, bounded capability used by every operation on this Object.
|
||||||
|
pub fn entry(&self) -> &LinuxEntry {
|
||||||
|
&self.entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl WipObject for DefaultObject {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
self.entry.name()
|
||||||
|
}
|
||||||
|
fn description(&self) -> Option<&str> {
|
||||||
|
self.entry.description()
|
||||||
|
}
|
||||||
|
fn reference(&self) -> Option<&str> {
|
||||||
|
self.entry.reference()
|
||||||
|
}
|
||||||
|
fn validator(&self) -> Option<&Validator> {
|
||||||
|
self.entry.validator()
|
||||||
|
}
|
||||||
|
fn requires_validator(&self, interface: &InterfaceRef, operation: &str) -> bool {
|
||||||
|
interface == &self.write && operation == "replace"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl FileRead for DefaultObject {
|
||||||
|
fn read(&self, offset: Option<u64>, length: Option<u64>) -> wip::OperationResult<wip::Bytes> {
|
||||||
|
self.entry.read(offset, length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl SymlinkInspect for DefaultObject {
|
||||||
|
fn read_link(&self) -> wip::OperationResult<wip::Bytes> {
|
||||||
|
self.entry.read_link()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl FileWrite for DefaultObject {
|
||||||
|
fn replace(&self, contents: wip::Bytes) -> wip::OperationResult<()> {
|
||||||
|
self.entry.replace(contents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Composition<LinuxEntry> for DefaultComposition {
|
||||||
|
type Object = DefaultObject;
|
||||||
|
fn publish(
|
||||||
|
&self,
|
||||||
|
entry: LinuxEntry,
|
||||||
|
_: &ExecutionContext,
|
||||||
|
) -> Result<PublishedObject<Self::Object>, HostError> {
|
||||||
|
let kind = entry.kind();
|
||||||
|
let writable = entry.access_mode() == AccessMode::ReadWrite;
|
||||||
|
let mut publication = PublishedObject::new(DefaultObject {
|
||||||
|
entry,
|
||||||
|
write: self.interfaces.write.reference().clone(),
|
||||||
|
});
|
||||||
|
match kind {
|
||||||
|
EntryKind::Regular => {
|
||||||
|
publication = publication
|
||||||
|
.expose(&self.interfaces.read)
|
||||||
|
.map_err(publication_error)?;
|
||||||
|
if writable {
|
||||||
|
publication = publication
|
||||||
|
.expose(&self.interfaces.write)
|
||||||
|
.map_err(publication_error)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EntryKind::Symlink => {
|
||||||
|
publication = publication
|
||||||
|
.expose(&self.interfaces.symlink)
|
||||||
|
.map_err(publication_error)?;
|
||||||
|
}
|
||||||
|
EntryKind::Directory | EntryKind::Unsupported => {}
|
||||||
|
}
|
||||||
|
Ok(publication)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn publication_error(_: wip_host::PublicationError) -> HostError {
|
||||||
|
HostError::internal(
|
||||||
|
"default publication failed",
|
||||||
|
wip_host::EffectGuarantee::NotCommitted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct the secure-by-default Linux engine without binding a listener.
|
||||||
|
pub fn default_projection(
|
||||||
|
root: &std::path::Path,
|
||||||
|
access: AccessMode,
|
||||||
|
limits: ResourceLimits,
|
||||||
|
) -> Result<Projection<LinuxBackend, DefaultComposition, DefaultPolicy>, std::io::Error> {
|
||||||
|
let backend = LinuxBackend::open(root, access, limits)?;
|
||||||
|
let mut registry = InterfaceRegistry::new();
|
||||||
|
let interfaces = DefaultInterfaces::register(&mut registry).map_err(std::io::Error::other)?;
|
||||||
|
Ok(Projection::new(
|
||||||
|
backend,
|
||||||
|
registry,
|
||||||
|
DefaultComposition::new(interfaces),
|
||||||
|
DefaultPolicy,
|
||||||
|
limits,
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -0,0 +1,661 @@
|
|||||||
|
//! Transport-independent projection, publication, and principal view boundaries.
|
||||||
|
|
||||||
|
use crate::ResourceLimits;
|
||||||
|
use wip_host::{
|
||||||
|
EffectGuarantee, ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, ObjectProvider,
|
||||||
|
OperationAttempt, ProviderResult, PublishedObject, WipObject,
|
||||||
|
};
|
||||||
|
use wip_protocol::{
|
||||||
|
FetchInterfaceRequest, FetchInterfaceResponse, ObjectObservation, ObserveRequest,
|
||||||
|
ObserveResponse, ProtocolError, ProtocolErrorCode,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Portable entry classification; capabilities must not be inferred from kind alone.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum EntryKind {
|
||||||
|
/// Container whose children can be enumerated.
|
||||||
|
Directory,
|
||||||
|
/// Regular data entry.
|
||||||
|
Regular,
|
||||||
|
/// Link itself, never implicitly followed.
|
||||||
|
Symlink,
|
||||||
|
/// Metadata-only entry unless the composition supplies a capability.
|
||||||
|
Unsupported,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A root-confined, request-time capability, not a path to resolve again.
|
||||||
|
///
|
||||||
|
/// Metadata must be safe for public exposure. Validators bind both identity and
|
||||||
|
/// protected state. Cloning must retain the same target rather than re-resolve it.
|
||||||
|
pub trait BackendEntry: WipObject + Clone {
|
||||||
|
/// Classification for observation and publication selection.
|
||||||
|
fn kind(&self) -> EntryKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single resolved target together with its retained root-to-parent lineage.
|
||||||
|
///
|
||||||
|
/// The backend must resolve a connected lineage, never independent raw-path
|
||||||
|
/// prefixes. At execution it validates ancestor membership/identity, refreshes
|
||||||
|
/// their policy metadata, and pins that snapshot through dispatch. Target
|
||||||
|
/// replacement must not redirect the original capability. This value is private
|
||||||
|
/// backend context, not protocol metadata.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ResolvedEntry<E> {
|
||||||
|
ancestors: Vec<E>,
|
||||||
|
target: E,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<E> ResolvedEntry<E> {
|
||||||
|
/// Create backend resolution context. `/` has no ancestors; a non-root target
|
||||||
|
/// has the pinned root first, followed by its intermediate parent containers.
|
||||||
|
pub fn new(ancestors: Vec<E>, target: E) -> Self {
|
||||||
|
Self { ancestors, target }
|
||||||
|
}
|
||||||
|
/// Pinned ancestor capabilities, ordered from the root to the direct parent.
|
||||||
|
pub fn ancestors(&self) -> &[E] {
|
||||||
|
&self.ancestors
|
||||||
|
}
|
||||||
|
/// The original resolved target capability, never a redirected replacement.
|
||||||
|
pub fn target(&self) -> &E {
|
||||||
|
&self.target
|
||||||
|
}
|
||||||
|
/// Consume this snapshot for refresh or request-time publication.
|
||||||
|
pub fn into_parts(self) -> (Vec<E>, E) {
|
||||||
|
(self.ancestors, self.target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trusted backend boundary. Implementations need not use POSIX semantics.
|
||||||
|
///
|
||||||
|
/// Resolve and enumerate pinned capabilities within the configured root. Enforce
|
||||||
|
/// context cancellation, deadlines and work budgets during backend work. Do not
|
||||||
|
/// advertise capabilities the backend cannot guarantee. Errors must not include
|
||||||
|
/// backend paths, credentials or hidden metadata.
|
||||||
|
///
|
||||||
|
/// `execute` must retain the same target and complete lineage and serialize
|
||||||
|
/// ancestor identity/membership checks, policy metadata, validator observation,
|
||||||
|
/// publication, validation, typed dispatch and commit/rollback. Changed/detached
|
||||||
|
/// ancestors must return `NotFound` before the callback; never substitute a new
|
||||||
|
/// ancestor or redirect the target by re-resolving its raw path. Refresh *all*
|
||||||
|
/// lineage metadata/validators inside the boundary and retain that snapshot
|
||||||
|
/// through dispatch, then return the committed post-operation validator. Never
|
||||||
|
/// upgrade an unknown outcome except after confirmed rollback. Locks only protect
|
||||||
|
/// actors participating in the protocol; document any external-writer limits.
|
||||||
|
pub trait FilesystemBackend {
|
||||||
|
/// Stable capability supplied to the composition.
|
||||||
|
type Entry: BackendEntry;
|
||||||
|
/// Resolve a canonical WIP path and retain its connected ancestry.
|
||||||
|
///
|
||||||
|
/// Obtain the target and lineage in one backend traversal/snapshot. Enforce
|
||||||
|
/// configured handle/work budgets while retaining ancestors; do not silently
|
||||||
|
/// omit ancestry on resource exhaustion.
|
||||||
|
fn resolve(
|
||||||
|
&self,
|
||||||
|
context: &ExecutionContext,
|
||||||
|
path: &str,
|
||||||
|
) -> ProviderResult<ResolvedEntry<Self::Entry>>;
|
||||||
|
/// Enumerate direct children, at most `maximum`; excess must fail, not truncate.
|
||||||
|
fn children(
|
||||||
|
&self,
|
||||||
|
context: &ExecutionContext,
|
||||||
|
entry: &Self::Entry,
|
||||||
|
maximum: usize,
|
||||||
|
) -> Result<Vec<Self::Entry>, HostError>;
|
||||||
|
/// Execute against this already-resolved target within an atomic boundary.
|
||||||
|
fn execute(
|
||||||
|
&self,
|
||||||
|
context: &ExecutionContext,
|
||||||
|
entry: ResolvedEntry<Self::Entry>,
|
||||||
|
operation: impl FnOnce(ResolvedEntry<Self::Entry>) -> OperationAttempt,
|
||||||
|
) -> ProviderResult<OperationAttempt>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Typed publication extension point, implementable on an external crate's type.
|
||||||
|
///
|
||||||
|
/// Use `InterfaceRegistry::register` and `PublishedObject::expose` with generated
|
||||||
|
/// definitions. An external local Object can wrap the entry and implement any
|
||||||
|
/// number of interfaces, avoiding orphan rules. Its `WipObject` implementation
|
||||||
|
/// declares validator requirements per qualified operation. Operation failures
|
||||||
|
/// declare their own `EffectGuarantee`, not a fixed read/write classification.
|
||||||
|
pub trait Composition<E: BackendEntry> {
|
||||||
|
/// Local typed operation implementation and safe public metadata.
|
||||||
|
type Object: WipObject;
|
||||||
|
/// Build a request-scoped publication using this exact capability/context.
|
||||||
|
///
|
||||||
|
/// Publication and policy hooks must be side-effect-free with respect to the
|
||||||
|
/// backend. A publication failure is a pre-dispatch rejection, never a
|
||||||
|
/// started Operation. Delegate metadata/validators to the boundary snapshot.
|
||||||
|
fn publish(
|
||||||
|
&self,
|
||||||
|
entry: E,
|
||||||
|
context: &ExecutionContext,
|
||||||
|
) -> Result<PublishedObject<Self::Object>, HostError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Principal-specific view policy, independent of authentication transport.
|
||||||
|
///
|
||||||
|
/// Keep decisions deterministic and stable within an effective Worldspace view.
|
||||||
|
/// A changed policy requires a new view/reference lifetime (or interface
|
||||||
|
/// validators). Operation visibility is interface-wide within a view so every
|
||||||
|
/// published reference has exactly the descriptor returned by fetch. Entry-
|
||||||
|
/// specific differences should select different registered interfaces. Hooks
|
||||||
|
/// cannot add implementations, metadata, or raw paths to a publication.
|
||||||
|
pub trait ProjectionPolicy<E: BackendEntry, O: WipObject> {
|
||||||
|
/// Opaque deployment-established principal/request identity.
|
||||||
|
type Principal;
|
||||||
|
/// Whether the entry is visible. Invisible ancestors hide their whole subtree.
|
||||||
|
fn object_visible(&self, principal: &Self::Principal, entry: &E) -> bool;
|
||||||
|
/// Whether an interface descriptor may be fetched in this view.
|
||||||
|
fn interface_visible(&self, _principal: &Self::Principal, _interface: &InterfaceRef) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
/// Select/order a subset of implemented interfaces; defaults to all.
|
||||||
|
fn interfaces(
|
||||||
|
&self,
|
||||||
|
_principal: &Self::Principal,
|
||||||
|
_entry: &E,
|
||||||
|
available: &[InterfaceRef],
|
||||||
|
) -> Vec<InterfaceRef> {
|
||||||
|
available.to_vec()
|
||||||
|
}
|
||||||
|
/// Whether an operation is present in this view's descriptor and dispatch.
|
||||||
|
fn operation_visible(
|
||||||
|
&self,
|
||||||
|
_principal: &Self::Principal,
|
||||||
|
_interface: &InterfaceRef,
|
||||||
|
_operation: &str,
|
||||||
|
) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
/// Execution permission for a visible operation, after canonical preconditions.
|
||||||
|
fn authorize(
|
||||||
|
&self,
|
||||||
|
_principal: &Self::Principal,
|
||||||
|
_object: &O,
|
||||||
|
_interface: &InterfaceRef,
|
||||||
|
_operation: &str,
|
||||||
|
) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No authentication assumption: all configured objects/operations are visible.
|
||||||
|
pub struct DefaultPolicy;
|
||||||
|
impl<E: BackendEntry, O: WipObject> ProjectionPolicy<E, O> for DefaultPolicy {
|
||||||
|
type Principal = ();
|
||||||
|
fn object_visible(&self, _: &(), _: &E) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonical request-time engine, independent of HTTP and Linux.
|
||||||
|
pub struct Projection<B, C, P> {
|
||||||
|
backend: B,
|
||||||
|
registry: InterfaceRegistry,
|
||||||
|
composition: C,
|
||||||
|
policy: P,
|
||||||
|
limits: ResourceLimits,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<B, C, P> Projection<B, C, P>
|
||||||
|
where
|
||||||
|
B: FilesystemBackend,
|
||||||
|
C: Composition<B::Entry>,
|
||||||
|
P: ProjectionPolicy<B::Entry, C::Object>,
|
||||||
|
{
|
||||||
|
/// Assemble trusted components. The registry must contain all exposed tokens.
|
||||||
|
pub fn new(
|
||||||
|
backend: B,
|
||||||
|
registry: InterfaceRegistry,
|
||||||
|
composition: C,
|
||||||
|
policy: P,
|
||||||
|
limits: ResourceLimits,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
backend,
|
||||||
|
registry,
|
||||||
|
composition,
|
||||||
|
policy,
|
||||||
|
limits,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Backend access for trusted composition/configuration, never serialized.
|
||||||
|
pub fn backend(&self) -> &B {
|
||||||
|
&self.backend
|
||||||
|
}
|
||||||
|
/// Borrow one effective principal view for observation, fetch and dispatch.
|
||||||
|
pub fn view<'a>(&'a self, principal: &'a P::Principal) -> ProjectionView<'a, B, C, P> {
|
||||||
|
ProjectionView {
|
||||||
|
projection: self,
|
||||||
|
principal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A coherent principal view implementing canonical typed host dispatch.
|
||||||
|
pub struct ProjectionView<'a, B, C, P>
|
||||||
|
where
|
||||||
|
B: FilesystemBackend,
|
||||||
|
C: Composition<B::Entry>,
|
||||||
|
P: ProjectionPolicy<B::Entry, C::Object>,
|
||||||
|
{
|
||||||
|
projection: &'a Projection<B, C, P>,
|
||||||
|
principal: &'a P::Principal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<B, C, P> ProjectionView<'_, B, C, P>
|
||||||
|
where
|
||||||
|
B: FilesystemBackend,
|
||||||
|
C: Composition<B::Entry>,
|
||||||
|
P: ProjectionPolicy<B::Entry, C::Object>,
|
||||||
|
{
|
||||||
|
fn resolve(
|
||||||
|
&self,
|
||||||
|
context: &ExecutionContext,
|
||||||
|
path: &str,
|
||||||
|
) -> ProviderResult<ResolvedEntry<B::Entry>> {
|
||||||
|
if let Err(error) = preflight(context) {
|
||||||
|
return ProviderResult::Failure(error);
|
||||||
|
}
|
||||||
|
if wip_protocol::validate_path(path).is_err() {
|
||||||
|
return ProviderResult::NotFound;
|
||||||
|
}
|
||||||
|
match self.projection.backend.resolve(context, path) {
|
||||||
|
ProviderResult::Found(resolved) => {
|
||||||
|
if !lineage_matches_path(&resolved, path) {
|
||||||
|
return ProviderResult::Failure(internal());
|
||||||
|
}
|
||||||
|
if !self.lineage_visible(&resolved) {
|
||||||
|
return ProviderResult::NotFound;
|
||||||
|
}
|
||||||
|
ProviderResult::Found(resolved)
|
||||||
|
}
|
||||||
|
ProviderResult::NotFound => ProviderResult::NotFound,
|
||||||
|
ProviderResult::Failure(error) => ProviderResult::Failure(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lineage_visible(&self, resolved: &ResolvedEntry<B::Entry>) -> bool {
|
||||||
|
resolved
|
||||||
|
.ancestors()
|
||||||
|
.iter()
|
||||||
|
.chain(std::iter::once(resolved.target()))
|
||||||
|
.all(|entry| self.projection.policy.object_visible(self.principal, entry))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publication(
|
||||||
|
&self,
|
||||||
|
entry: B::Entry,
|
||||||
|
context: &ExecutionContext,
|
||||||
|
) -> Result<PublishedObject<C::Object>, HostError> {
|
||||||
|
let publication = self
|
||||||
|
.projection
|
||||||
|
.composition
|
||||||
|
.publish(entry.clone(), context)?;
|
||||||
|
let available = publication
|
||||||
|
.protocol_object()
|
||||||
|
.interfaces
|
||||||
|
.iter()
|
||||||
|
.map(|reference| {
|
||||||
|
self.projection
|
||||||
|
.registry
|
||||||
|
.resolve(reference)
|
||||||
|
.ok_or_else(internal)
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
let mut selected = self
|
||||||
|
.projection
|
||||||
|
.policy
|
||||||
|
.interfaces(self.principal, &entry, &available);
|
||||||
|
selected.retain(|reference| {
|
||||||
|
self.projection
|
||||||
|
.policy
|
||||||
|
.interface_visible(self.principal, reference)
|
||||||
|
});
|
||||||
|
publication
|
||||||
|
.project(&selected, |reference, operation| {
|
||||||
|
self.projection
|
||||||
|
.policy
|
||||||
|
.operation_visible(self.principal, reference, operation)
|
||||||
|
})
|
||||||
|
.map_err(|_| internal())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve the descriptor with precisely this principal's visible operations.
|
||||||
|
pub fn fetch_interface(
|
||||||
|
&self,
|
||||||
|
request: &FetchInterfaceRequest,
|
||||||
|
) -> Result<FetchInterfaceResponse, ProtocolError> {
|
||||||
|
read_boundary(|| self.fetch_interface_inner(request))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_interface_inner(
|
||||||
|
&self,
|
||||||
|
request: &FetchInterfaceRequest,
|
||||||
|
) -> Result<FetchInterfaceResponse, ProtocolError> {
|
||||||
|
let observation = self
|
||||||
|
.projection
|
||||||
|
.registry
|
||||||
|
.fetch_interface(&request.interface)?;
|
||||||
|
if !self
|
||||||
|
.projection
|
||||||
|
.policy
|
||||||
|
.interface_visible(self.principal, &observation.reference)
|
||||||
|
{
|
||||||
|
return Err(protocol(
|
||||||
|
ProtocolErrorCode::InterfaceNotFound,
|
||||||
|
"interface is not published",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let reference = observation.reference.clone();
|
||||||
|
observation
|
||||||
|
.project_operations(|operation| {
|
||||||
|
self.projection
|
||||||
|
.policy
|
||||||
|
.operation_visible(self.principal, &reference, operation)
|
||||||
|
})
|
||||||
|
.protocol_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Observe a complete bounded tree, omitting invisible children and subtrees.
|
||||||
|
pub fn observe(
|
||||||
|
&self,
|
||||||
|
context: &ExecutionContext,
|
||||||
|
request: &ObserveRequest,
|
||||||
|
) -> Result<ObserveResponse, ProtocolError> {
|
||||||
|
read_boundary(|| self.observe_inner(context, request))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn observe_inner(
|
||||||
|
&self,
|
||||||
|
context: &ExecutionContext,
|
||||||
|
request: &ObserveRequest,
|
||||||
|
) -> Result<ObserveResponse, ProtocolError> {
|
||||||
|
request
|
||||||
|
.validate()
|
||||||
|
.map_err(|_| protocol(ProtocolErrorCode::InvalidRequest, "invalid observe request"))?;
|
||||||
|
if request.depth > self.projection.limits.max_observation_depth {
|
||||||
|
return Err(resource_protocol());
|
||||||
|
}
|
||||||
|
let entry = match self.resolve(context, &request.path) {
|
||||||
|
ProviderResult::Found(entry) => entry,
|
||||||
|
ProviderResult::NotFound => {
|
||||||
|
return Err(protocol(
|
||||||
|
ProtocolErrorCode::NotFound,
|
||||||
|
"target path is not published",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
ProviderResult::Failure(error) => return Err(read_error(error)),
|
||||||
|
};
|
||||||
|
let (_ancestors, entry) = entry.into_parts();
|
||||||
|
let mut remaining = u64::from(self.projection.limits.max_observation_nodes)
|
||||||
|
.min(context.resource_budget().unwrap_or(u64::MAX));
|
||||||
|
let response = self.observe_entry(context, entry, request.depth, &mut remaining)?;
|
||||||
|
request.validate_response(&response).map_err(|_| {
|
||||||
|
protocol(
|
||||||
|
ProtocolErrorCode::Internal,
|
||||||
|
"invalid projection observation",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn observe_entry(
|
||||||
|
&self,
|
||||||
|
context: &ExecutionContext,
|
||||||
|
entry: B::Entry,
|
||||||
|
depth: u32,
|
||||||
|
remaining: &mut u64,
|
||||||
|
) -> Result<ObjectObservation, ProtocolError> {
|
||||||
|
preflight(context).map_err(read_error)?;
|
||||||
|
if *remaining == 0 {
|
||||||
|
return Err(resource_protocol());
|
||||||
|
}
|
||||||
|
*remaining -= 1;
|
||||||
|
let object = self
|
||||||
|
.publication(entry.clone(), context)
|
||||||
|
.map_err(read_error)?
|
||||||
|
.protocol_object();
|
||||||
|
if depth == 0 {
|
||||||
|
return Ok(ObjectObservation {
|
||||||
|
object,
|
||||||
|
children: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut children = Vec::new();
|
||||||
|
if entry.kind() == EntryKind::Directory {
|
||||||
|
let entries = self
|
||||||
|
.projection
|
||||||
|
.backend
|
||||||
|
.children(
|
||||||
|
context,
|
||||||
|
&entry,
|
||||||
|
usize::try_from(*remaining).unwrap_or(usize::MAX),
|
||||||
|
)
|
||||||
|
.map_err(read_error)?;
|
||||||
|
if entries.len() as u64 > *remaining {
|
||||||
|
return Err(resource_protocol());
|
||||||
|
}
|
||||||
|
for child in entries {
|
||||||
|
if self
|
||||||
|
.projection
|
||||||
|
.policy
|
||||||
|
.object_visible(self.principal, &child)
|
||||||
|
{
|
||||||
|
children.push(self.observe_entry(context, child, depth - 1, remaining)?);
|
||||||
|
} else {
|
||||||
|
if *remaining == 0 {
|
||||||
|
return Err(resource_protocol());
|
||||||
|
}
|
||||||
|
*remaining -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ObjectObservation {
|
||||||
|
object,
|
||||||
|
children: Some(children),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<B, C, P> ObjectProvider for ProjectionView<'_, B, C, P>
|
||||||
|
where
|
||||||
|
B: FilesystemBackend,
|
||||||
|
C: Composition<B::Entry>,
|
||||||
|
P: ProjectionPolicy<B::Entry, C::Object>,
|
||||||
|
{
|
||||||
|
type Object = C::Object;
|
||||||
|
fn with_object<R>(
|
||||||
|
&self,
|
||||||
|
context: &ExecutionContext,
|
||||||
|
path: &str,
|
||||||
|
operation: impl FnOnce(&PublishedObject<Self::Object>) -> R,
|
||||||
|
) -> ProviderResult<R> {
|
||||||
|
match self.resolve(context, path) {
|
||||||
|
ProviderResult::Found(resolved) => {
|
||||||
|
let (_ancestors, entry) = resolved.into_parts();
|
||||||
|
match self.publication(entry, context) {
|
||||||
|
Ok(publication) => ProviderResult::Found(operation(&publication)),
|
||||||
|
Err(error) => ProviderResult::Failure(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ProviderResult::NotFound => ProviderResult::NotFound,
|
||||||
|
ProviderResult::Failure(error) => ProviderResult::Failure(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn execute_operation(
|
||||||
|
&self,
|
||||||
|
context: &ExecutionContext,
|
||||||
|
path: &str,
|
||||||
|
operation: impl FnOnce(&PublishedObject<Self::Object>) -> OperationAttempt,
|
||||||
|
) -> ProviderResult<OperationAttempt> {
|
||||||
|
let entry = match self.resolve(context, path) {
|
||||||
|
ProviderResult::Found(entry) => entry,
|
||||||
|
ProviderResult::NotFound => return ProviderResult::NotFound,
|
||||||
|
ProviderResult::Failure(error) => return ProviderResult::Failure(error),
|
||||||
|
};
|
||||||
|
self.projection.backend.execute(context, entry, |resolved| {
|
||||||
|
// Backend validates retained lineage membership/identity and refreshes
|
||||||
|
// all metadata before this callback under the same boundary as dispatch.
|
||||||
|
if !lineage_matches_path(&resolved, path) {
|
||||||
|
return OperationAttempt::reject(wip_host::CallErrorKind::Internal);
|
||||||
|
}
|
||||||
|
if !self.lineage_visible(&resolved) {
|
||||||
|
return OperationAttempt::reject(wip_host::CallErrorKind::NotFound);
|
||||||
|
}
|
||||||
|
let (_ancestors, entry) = resolved.into_parts();
|
||||||
|
if let Err(error) = preflight(context) {
|
||||||
|
return OperationAttempt::reject(match error.kind() {
|
||||||
|
wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal,
|
||||||
|
wip_host::HostErrorKind::ResourceLimitExceeded => {
|
||||||
|
wip_host::CallErrorKind::ResourceLimitExceeded
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
match self.publication(entry, context) {
|
||||||
|
Ok(publication) => operation(&publication),
|
||||||
|
Err(error) => OperationAttempt::reject(match error.kind() {
|
||||||
|
wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal,
|
||||||
|
wip_host::HostErrorKind::ResourceLimitExceeded => {
|
||||||
|
wip_host::CallErrorKind::ResourceLimitExceeded
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn authorize(
|
||||||
|
&self,
|
||||||
|
_: &str,
|
||||||
|
object: &Self::Object,
|
||||||
|
interface: &InterfaceRef,
|
||||||
|
operation: &str,
|
||||||
|
) -> bool {
|
||||||
|
self.projection
|
||||||
|
.policy
|
||||||
|
.authorize(self.principal, object, interface, operation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<B, C, P> ObjectProvider for Projection<B, C, P>
|
||||||
|
where
|
||||||
|
B: FilesystemBackend,
|
||||||
|
C: Composition<B::Entry>,
|
||||||
|
P: ProjectionPolicy<B::Entry, C::Object, Principal = ()>,
|
||||||
|
{
|
||||||
|
type Object = C::Object;
|
||||||
|
fn with_object<R>(
|
||||||
|
&self,
|
||||||
|
context: &ExecutionContext,
|
||||||
|
path: &str,
|
||||||
|
operation: impl FnOnce(&PublishedObject<Self::Object>) -> R,
|
||||||
|
) -> ProviderResult<R> {
|
||||||
|
self.view(&()).with_object(context, path, operation)
|
||||||
|
}
|
||||||
|
fn execute_operation(
|
||||||
|
&self,
|
||||||
|
context: &ExecutionContext,
|
||||||
|
path: &str,
|
||||||
|
operation: impl FnOnce(&PublishedObject<Self::Object>) -> OperationAttempt,
|
||||||
|
) -> ProviderResult<OperationAttempt> {
|
||||||
|
self.view(&()).execute_operation(context, path, operation)
|
||||||
|
}
|
||||||
|
fn authorize(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
object: &Self::Object,
|
||||||
|
interface: &InterfaceRef,
|
||||||
|
operation: &str,
|
||||||
|
) -> bool {
|
||||||
|
self.view(&()).authorize(path, object, interface, operation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<B, C, P> Projection<B, C, P>
|
||||||
|
where
|
||||||
|
B: FilesystemBackend,
|
||||||
|
C: Composition<B::Entry>,
|
||||||
|
P: ProjectionPolicy<B::Entry, C::Object, Principal = ()>,
|
||||||
|
{
|
||||||
|
/// Observe the default unit-principal view.
|
||||||
|
pub fn observe(
|
||||||
|
&self,
|
||||||
|
context: &ExecutionContext,
|
||||||
|
request: &ObserveRequest,
|
||||||
|
) -> Result<ObserveResponse, ProtocolError> {
|
||||||
|
self.view(&()).observe(context, request)
|
||||||
|
}
|
||||||
|
/// Fetch in the default unit-principal view.
|
||||||
|
pub fn fetch_interface(
|
||||||
|
&self,
|
||||||
|
request: &FetchInterfaceRequest,
|
||||||
|
) -> Result<FetchInterfaceResponse, ProtocolError> {
|
||||||
|
self.view(&()).fetch_interface(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lineage_matches_path<E: BackendEntry>(resolved: &ResolvedEntry<E>, path: &str) -> bool {
|
||||||
|
let names: Vec<_> = if path == "/" {
|
||||||
|
vec![""]
|
||||||
|
} else {
|
||||||
|
std::iter::once("").chain(path[1..].split('/')).collect()
|
||||||
|
};
|
||||||
|
if resolved.ancestors().len() + 1 != names.len()
|
||||||
|
|| resolved.target().name() != names[names.len() - 1]
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
resolved
|
||||||
|
.ancestors()
|
||||||
|
.iter()
|
||||||
|
.zip(names)
|
||||||
|
.all(|(entry, name)| entry.kind() == EntryKind::Directory && entry.name() == name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_boundary<T>(
|
||||||
|
operation: impl FnOnce() -> Result<T, ProtocolError>,
|
||||||
|
) -> Result<T, ProtocolError> {
|
||||||
|
std::panic::catch_unwind(std::panic::AssertUnwindSafe(operation))
|
||||||
|
.unwrap_or_else(|_| Err(protocol(ProtocolErrorCode::Internal, "projection failed")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn internal() -> HostError {
|
||||||
|
HostError::internal(
|
||||||
|
"projection publication failed",
|
||||||
|
EffectGuarantee::NotCommitted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fn protocol(code: ProtocolErrorCode, message: &str) -> ProtocolError {
|
||||||
|
ProtocolError {
|
||||||
|
code,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn resource_protocol() -> ProtocolError {
|
||||||
|
protocol(
|
||||||
|
ProtocolErrorCode::ResourceLimitExceeded,
|
||||||
|
"projection resource limit exceeded",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fn read_error(error: HostError) -> ProtocolError {
|
||||||
|
match error.kind() {
|
||||||
|
wip_host::HostErrorKind::ResourceLimitExceeded => resource_protocol(),
|
||||||
|
wip_host::HostErrorKind::Internal => {
|
||||||
|
protocol(ProtocolErrorCode::Internal, "projection failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn preflight(context: &ExecutionContext) -> Result<(), HostError> {
|
||||||
|
if context.cancellation().is_cancelled()
|
||||||
|
|| context
|
||||||
|
.deadline()
|
||||||
|
.is_some_and(|deadline| std::time::Instant::now() >= deadline)
|
||||||
|
|| context.resource_budget() == Some(0)
|
||||||
|
{
|
||||||
|
Err(HostError::resource_limit(
|
||||||
|
"projection work limit exceeded",
|
||||||
|
EffectGuarantee::NotCommitted,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
//! Framework-neutral standard WIP over HTTP mapping for a projection view.
|
||||||
|
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use http::header::ALLOW;
|
||||||
|
use http::{Request, Response, StatusCode};
|
||||||
|
use wip_host::{ExecutionContext, ObjectProvider};
|
||||||
|
use wip_http::{
|
||||||
|
CodecError, EncodeHttpError, Endpoint, RequestMetadataError, Route,
|
||||||
|
decode_call_operation_metadata, decode_fetch_interface_request, decode_observe_request,
|
||||||
|
encode_call_operation_response, encode_fetch_interface_response, encode_observe_response,
|
||||||
|
encode_protocol_error_response, validate_http_request,
|
||||||
|
};
|
||||||
|
use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction};
|
||||||
|
|
||||||
|
use crate::engine::ProjectionView;
|
||||||
|
use crate::{Composition, FilesystemBackend, ProjectionPolicy, ResourceLimits};
|
||||||
|
|
||||||
|
/// Standard HTTP routes and codecs over a caller-scoped projection view.
|
||||||
|
///
|
||||||
|
/// The adapter borrows the view and endpoint without owning a listener, selecting
|
||||||
|
/// a principal, or imposing a new execution context. Integrations must enforce
|
||||||
|
/// the request body limit while collecting bytes, before calling this adapter.
|
||||||
|
pub struct HttpAdapter<'a, 'view, B, C, P>
|
||||||
|
where
|
||||||
|
B: FilesystemBackend,
|
||||||
|
C: Composition<B::Entry>,
|
||||||
|
P: ProjectionPolicy<B::Entry, C::Object>,
|
||||||
|
{
|
||||||
|
view: &'a ProjectionView<'view, B, C, P>,
|
||||||
|
endpoint: &'a Endpoint,
|
||||||
|
limits: ResourceLimits,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, 'view, B, C, P> HttpAdapter<'a, 'view, B, C, P>
|
||||||
|
where
|
||||||
|
B: FilesystemBackend,
|
||||||
|
C: Composition<B::Entry>,
|
||||||
|
P: ProjectionPolicy<B::Entry, C::Object>,
|
||||||
|
{
|
||||||
|
/// Creates an adapter for an already-selected principal's projection view.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
view: &'a ProjectionView<'view, B, C, P>,
|
||||||
|
endpoint: &'a Endpoint,
|
||||||
|
limits: ResourceLimits,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
view,
|
||||||
|
endpoint,
|
||||||
|
limits,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles one buffered request using the supplied execution controls.
|
||||||
|
///
|
||||||
|
/// Observation and operation dispatch receive the original context, including
|
||||||
|
/// cancellation and resource budget. An elapsed deadline after dispatch keeps
|
||||||
|
/// the listener's existing empty HTTP 504 transport-failure behavior.
|
||||||
|
#[must_use]
|
||||||
|
pub fn handle_http_with_context(
|
||||||
|
&self,
|
||||||
|
request: Request<Vec<u8>>,
|
||||||
|
context: &ExecutionContext,
|
||||||
|
) -> Response<Vec<u8>> {
|
||||||
|
let Some(route) = self.route(request.uri()) else {
|
||||||
|
return empty_response(StatusCode::NOT_FOUND);
|
||||||
|
};
|
||||||
|
if let Err(error) = validate_http_request(self.endpoint, route, &request) {
|
||||||
|
return self.metadata_error(error);
|
||||||
|
}
|
||||||
|
let response = match route {
|
||||||
|
Route::Observe => self.observe(request.body(), context),
|
||||||
|
Route::FetchInterface => self.fetch_interface(request.body()),
|
||||||
|
Route::CallOperation => self.call_operation(request.body(), context),
|
||||||
|
};
|
||||||
|
if context
|
||||||
|
.deadline()
|
||||||
|
.is_some_and(|deadline| Instant::now() >= deadline)
|
||||||
|
{
|
||||||
|
empty_response(StatusCode::GATEWAY_TIMEOUT)
|
||||||
|
} else {
|
||||||
|
response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn route(&self, uri: &http::Uri) -> Option<Route> {
|
||||||
|
if uri.query().is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
[Route::Observe, Route::FetchInterface, Route::CallOperation]
|
||||||
|
.into_iter()
|
||||||
|
.find(|route| self.endpoint.route(*route).path() == uri.path())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn observe(&self, body: &[u8], context: &ExecutionContext) -> Response<Vec<u8>> {
|
||||||
|
let request = match decode_observe_request(body, self.limits.wire) {
|
||||||
|
Ok(request) => request,
|
||||||
|
Err(error) => return self.codec_error(ProtocolInteraction::Observe, error),
|
||||||
|
};
|
||||||
|
match self.view.observe(context, &request) {
|
||||||
|
Ok(tree) => {
|
||||||
|
encode_observe_response(&request, &tree, self.limits.wire).unwrap_or_else(|error| {
|
||||||
|
self.read_encode_error(ProtocolInteraction::Observe, error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(error) => self.protocol_error(ProtocolInteraction::Observe, &error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_interface(&self, body: &[u8]) -> Response<Vec<u8>> {
|
||||||
|
let request = match decode_fetch_interface_request(body, self.limits.wire) {
|
||||||
|
Ok(request) => request,
|
||||||
|
Err(error) => return self.codec_error(ProtocolInteraction::FetchInterface, error),
|
||||||
|
};
|
||||||
|
match self.view.fetch_interface(&request) {
|
||||||
|
Ok(interface) => {
|
||||||
|
encode_fetch_interface_response(&request, &interface, self.limits.wire)
|
||||||
|
.unwrap_or_else(|error| {
|
||||||
|
self.read_encode_error(ProtocolInteraction::FetchInterface, error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(error) => self.protocol_error(ProtocolInteraction::FetchInterface, &error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call_operation(&self, body: &[u8], context: &ExecutionContext) -> Response<Vec<u8>> {
|
||||||
|
let metadata = match decode_call_operation_metadata(body, self.limits.wire) {
|
||||||
|
Ok(metadata) => metadata,
|
||||||
|
Err(error) => return self.codec_error(ProtocolInteraction::CallOperation, error),
|
||||||
|
};
|
||||||
|
let decoded = std::sync::Mutex::new(None);
|
||||||
|
let execution = self.view.call_operation_with_context_and_decoder(
|
||||||
|
context,
|
||||||
|
metadata.target.clone(),
|
||||||
|
metadata.interface.clone(),
|
||||||
|
metadata.operation.clone(),
|
||||||
|
|descriptor| {
|
||||||
|
let request = metadata.decode_request(body, descriptor, self.limits.wire)?;
|
||||||
|
let arguments = request.arguments.clone();
|
||||||
|
*decoded.lock().unwrap_or_else(|error| error.into_inner()) =
|
||||||
|
Some((request, descriptor.clone()));
|
||||||
|
Ok::<_, CodecError>(arguments)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
match execution.map_err(|error| error.protocol_error()) {
|
||||||
|
Ok(result) => {
|
||||||
|
let Some((request, descriptor)) = decoded
|
||||||
|
.into_inner()
|
||||||
|
.unwrap_or_else(|error| error.into_inner())
|
||||||
|
else {
|
||||||
|
return self.protocol_error(
|
||||||
|
ProtocolInteraction::CallOperation,
|
||||||
|
&operation_outcome_unknown(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
encode_call_operation_response(&request, &descriptor, &result, self.limits.wire)
|
||||||
|
.unwrap_or_else(|error| self.operation_encode_error(error))
|
||||||
|
}
|
||||||
|
Err(error) => self.protocol_error(ProtocolInteraction::CallOperation, &error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn operation_encode_error(&self, error: EncodeHttpError) -> Response<Vec<u8>> {
|
||||||
|
if matches!(&error, EncodeHttpError::Codec(codec) if is_transport_limit(codec)) {
|
||||||
|
return empty_response(StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
// Any successfully executed operation may have committed effects. Its
|
||||||
|
// name cannot establish a non-commit guarantee after response failure.
|
||||||
|
self.protocol_error(
|
||||||
|
ProtocolInteraction::CallOperation,
|
||||||
|
&operation_outcome_unknown(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata_error(&self, error: RequestMetadataError) -> Response<Vec<u8>> {
|
||||||
|
match error {
|
||||||
|
RequestMetadataError::NotFound => empty_response(StatusCode::NOT_FOUND),
|
||||||
|
error @ RequestMetadataError::MethodNotAllowed => Response::builder()
|
||||||
|
.status(error.status())
|
||||||
|
.header(
|
||||||
|
ALLOW,
|
||||||
|
error
|
||||||
|
.allow()
|
||||||
|
.expect("method-not-allowed metadata has an Allow value"),
|
||||||
|
)
|
||||||
|
.body(Vec::new())
|
||||||
|
.expect("static method response is valid"),
|
||||||
|
RequestMetadataError::UnsupportedMediaType => {
|
||||||
|
empty_response(StatusCode::UNSUPPORTED_MEDIA_TYPE)
|
||||||
|
}
|
||||||
|
RequestMetadataError::NotAcceptable => empty_response(StatusCode::NOT_ACCEPTABLE),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codec_error(
|
||||||
|
&self,
|
||||||
|
interaction: ProtocolInteraction,
|
||||||
|
error: CodecError,
|
||||||
|
) -> Response<Vec<u8>> {
|
||||||
|
if is_transport_limit(&error) {
|
||||||
|
empty_response(StatusCode::PAYLOAD_TOO_LARGE)
|
||||||
|
} else {
|
||||||
|
self.protocol_error(
|
||||||
|
interaction,
|
||||||
|
&ProtocolError {
|
||||||
|
code: ProtocolErrorCode::InvalidRequest,
|
||||||
|
message: "malformed WIP request".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_encode_error(
|
||||||
|
&self,
|
||||||
|
interaction: ProtocolInteraction,
|
||||||
|
error: EncodeHttpError,
|
||||||
|
) -> Response<Vec<u8>> {
|
||||||
|
match error {
|
||||||
|
EncodeHttpError::Codec(codec) if is_transport_limit(&codec) => {
|
||||||
|
empty_response(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
|
}
|
||||||
|
_ => self.protocol_error(
|
||||||
|
interaction,
|
||||||
|
&ProtocolError {
|
||||||
|
code: ProtocolErrorCode::Internal,
|
||||||
|
message: "HTTP adapter failed".into(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn protocol_error(
|
||||||
|
&self,
|
||||||
|
interaction: ProtocolInteraction,
|
||||||
|
error: &ProtocolError,
|
||||||
|
) -> Response<Vec<u8>> {
|
||||||
|
// Engine and policy diagnostics may contain paths, argument values, or
|
||||||
|
// backend details. Emit only fixed, bounded diagnostics on the wire.
|
||||||
|
let sanitized = ProtocolError {
|
||||||
|
code: error.code,
|
||||||
|
message: sanitized_message(error.code).into(),
|
||||||
|
};
|
||||||
|
encode_protocol_error_response(interaction, &sanitized, self.limits.wire)
|
||||||
|
.unwrap_or_else(|_| empty_response(StatusCode::INTERNAL_SERVER_ERROR))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn operation_outcome_unknown() -> ProtocolError {
|
||||||
|
ProtocolError {
|
||||||
|
code: ProtocolErrorCode::OperationOutcomeUnknown,
|
||||||
|
message: "operation completed but its response could not be encoded".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitized_message(code: ProtocolErrorCode) -> &'static str {
|
||||||
|
match code {
|
||||||
|
ProtocolErrorCode::InvalidRequest => "malformed WIP request",
|
||||||
|
ProtocolErrorCode::NotFound => "target path is not published",
|
||||||
|
ProtocolErrorCode::InterfaceNotFound => "interface is not published",
|
||||||
|
ProtocolErrorCode::InterfaceMismatch => "interface is not published on the target",
|
||||||
|
ProtocolErrorCode::OperationNotFound => "operation is not published",
|
||||||
|
ProtocolErrorCode::InvalidArguments => "arguments do not match the operation",
|
||||||
|
ProtocolErrorCode::ValidatorRequired => "object validator is required",
|
||||||
|
ProtocolErrorCode::ValidatorMismatch => "object validator does not match",
|
||||||
|
ProtocolErrorCode::InterfaceValidatorRequired => "interface validator is required",
|
||||||
|
ProtocolErrorCode::InterfaceValidatorMismatch => "interface validator does not match",
|
||||||
|
ProtocolErrorCode::PermissionDenied => "operation is not permitted",
|
||||||
|
ProtocolErrorCode::ResourceLimitExceeded => "projection resource limit exceeded",
|
||||||
|
ProtocolErrorCode::Internal => "HTTP adapter failed",
|
||||||
|
ProtocolErrorCode::OperationOutcomeUnknown => "operation outcome is unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_transport_limit(error: &CodecError) -> bool {
|
||||||
|
matches!(
|
||||||
|
error,
|
||||||
|
CodecError::BodyTooLarge { .. } | CodecError::NestingTooDeep { .. }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_response(status: StatusCode) -> Response<Vec<u8>> {
|
||||||
|
Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.body(Vec::new())
|
||||||
|
.expect("static status builds an HTTP response")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
use wip_http::{
|
||||||
|
DecodedResponse, Limits, decode_fetch_interface_response, decode_observe_response,
|
||||||
|
encode_fetch_interface_request, encode_observe_request,
|
||||||
|
};
|
||||||
|
use wip_protocol::{FetchInterfaceRequest, ObserveRequest};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adapter_serves_observation_and_interface_fetch_without_server() {
|
||||||
|
let root = tempdir().unwrap();
|
||||||
|
std::fs::write(root.path().join("file"), b"contents").unwrap();
|
||||||
|
let limits = ResourceLimits::default();
|
||||||
|
let projection =
|
||||||
|
crate::default_projection(root.path(), crate::AccessMode::ReadOnly, limits).unwrap();
|
||||||
|
let view = projection.view(&());
|
||||||
|
let endpoint = Endpoint::parse("http://localhost/custom").unwrap();
|
||||||
|
let adapter = HttpAdapter::new(&view, &endpoint, limits);
|
||||||
|
let context = ExecutionContext::new();
|
||||||
|
let request = ObserveRequest {
|
||||||
|
path: "/file".into(),
|
||||||
|
depth: 0,
|
||||||
|
};
|
||||||
|
let response = adapter.handle_http_with_context(
|
||||||
|
encode_observe_request(&endpoint, &request, limits.wire).unwrap(),
|
||||||
|
&context,
|
||||||
|
);
|
||||||
|
let DecodedResponse::Success(observation) =
|
||||||
|
decode_observe_response(&request, &response, limits.wire).unwrap()
|
||||||
|
else {
|
||||||
|
panic!("expected observation success")
|
||||||
|
};
|
||||||
|
let request = FetchInterfaceRequest {
|
||||||
|
interface: observation.object.interfaces[0].clone(),
|
||||||
|
};
|
||||||
|
let response = adapter.handle_http_with_context(
|
||||||
|
encode_fetch_interface_request(&endpoint, &request, limits.wire).unwrap(),
|
||||||
|
&context,
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
decode_fetch_interface_response(&request, &response, limits.wire).unwrap(),
|
||||||
|
DecodedResponse::Success(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adapter_preserves_transport_boundaries_and_execution_context() {
|
||||||
|
let root = tempdir().unwrap();
|
||||||
|
let limits = ResourceLimits::default();
|
||||||
|
let projection =
|
||||||
|
crate::default_projection(root.path(), crate::AccessMode::ReadOnly, limits).unwrap();
|
||||||
|
let view = projection.view(&());
|
||||||
|
let endpoint = Endpoint::parse("http://localhost/wip").unwrap();
|
||||||
|
let adapter = HttpAdapter::new(&view, &endpoint, limits);
|
||||||
|
let context = ExecutionContext::new();
|
||||||
|
for uri in [
|
||||||
|
"/wip/v1/observe?query=1",
|
||||||
|
"/wip/v1/unknown",
|
||||||
|
"/wip/v1/observe/extra",
|
||||||
|
] {
|
||||||
|
let response = adapter.handle_http_with_context(
|
||||||
|
Request::builder().uri(uri).body(Vec::new()).unwrap(),
|
||||||
|
&context,
|
||||||
|
);
|
||||||
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
|
assert!(response.body().is_empty());
|
||||||
|
}
|
||||||
|
let response = adapter.handle_http_with_context(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/wip/v1/observe")
|
||||||
|
.body(Vec::new())
|
||||||
|
.unwrap(),
|
||||||
|
&context,
|
||||||
|
);
|
||||||
|
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
|
||||||
|
assert_eq!(response.headers()[ALLOW], "POST");
|
||||||
|
assert!(response.body().is_empty());
|
||||||
|
let request = ObserveRequest {
|
||||||
|
path: "/".into(),
|
||||||
|
depth: 0,
|
||||||
|
};
|
||||||
|
let response = adapter.handle_http_with_context(
|
||||||
|
encode_observe_request(&endpoint, &request, limits.wire).unwrap(),
|
||||||
|
&ExecutionContext::new().with_resource_budget(0),
|
||||||
|
);
|
||||||
|
let text = std::str::from_utf8(response.body()).unwrap();
|
||||||
|
assert!(text.contains("resource_limit_exceeded"));
|
||||||
|
let response = adapter.handle_http_with_context(
|
||||||
|
encode_observe_request(&endpoint, &request, limits.wire).unwrap(),
|
||||||
|
&ExecutionContext::new().with_deadline(Instant::now()),
|
||||||
|
);
|
||||||
|
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
|
||||||
|
assert!(response.body().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn post_success_encoding_is_unknown_except_empty_transport_limit_failure() {
|
||||||
|
let root = tempdir().unwrap();
|
||||||
|
let limits = ResourceLimits::default();
|
||||||
|
let projection =
|
||||||
|
crate::default_projection(root.path(), crate::AccessMode::ReadOnly, limits).unwrap();
|
||||||
|
let view = projection.view(&());
|
||||||
|
let endpoint = Endpoint::parse("http://localhost/wip").unwrap();
|
||||||
|
let adapter = HttpAdapter::new(&view, &endpoint, limits);
|
||||||
|
let error = decode_observe_request(b"{", limits.wire).unwrap_err();
|
||||||
|
let response = adapter.operation_encode_error(EncodeHttpError::Codec(error));
|
||||||
|
assert!(
|
||||||
|
std::str::from_utf8(response.body())
|
||||||
|
.unwrap()
|
||||||
|
.contains("operation_outcome_unknown")
|
||||||
|
);
|
||||||
|
let tiny_limits = Limits::new(1, 1, 1).unwrap();
|
||||||
|
let error = decode_observe_request(b"{}", tiny_limits).unwrap_err();
|
||||||
|
assert!(is_transport_limit(&error));
|
||||||
|
let response = adapter.operation_encode_error(EncodeHttpError::Codec(error));
|
||||||
|
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
assert!(response.body().is_empty());
|
||||||
|
assert!(!response.headers().contains_key(http::header::CONTENT_TYPE));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn engine_error_diagnostics_are_sanitized_and_bounded() {
|
||||||
|
let root = tempdir().unwrap();
|
||||||
|
let limits = ResourceLimits::default();
|
||||||
|
let projection =
|
||||||
|
crate::default_projection(root.path(), crate::AccessMode::ReadOnly, limits).unwrap();
|
||||||
|
let view = projection.view(&());
|
||||||
|
let endpoint = Endpoint::parse("http://localhost/wip").unwrap();
|
||||||
|
let adapter = HttpAdapter::new(&view, &endpoint, limits);
|
||||||
|
let response = adapter.protocol_error(
|
||||||
|
ProtocolInteraction::Observe,
|
||||||
|
&ProtocolError {
|
||||||
|
code: ProtocolErrorCode::Internal,
|
||||||
|
message: "/private/backend/secret".repeat(1000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(response.body().len() < 512);
|
||||||
|
assert!(
|
||||||
|
!std::str::from_utf8(response.body())
|
||||||
|
.unwrap()
|
||||||
|
.contains("secret")
|
||||||
|
);
|
||||||
|
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,12 +7,46 @@
|
|||||||
//! - [`wip_http`] owns routes, JSON, envelopes, and status mapping;
|
//! - [`wip_http`] owns routes, JSON, envelopes, and status mapping;
|
||||||
//! - this crate owns the filesystem capability boundary and the real HTTP listener.
|
//! - this crate owns the filesystem capability boundary and the real HTTP listener.
|
||||||
//!
|
//!
|
||||||
|
//! # Extension boundary
|
||||||
|
//!
|
||||||
|
//! [`Projection`] composes a [`FilesystemBackend`], typed [`Composition`] and
|
||||||
|
//! [`ProjectionPolicy`]. External local Objects can wrap a [`BackendEntry`] and
|
||||||
|
//! implement generated interfaces without orphan-rule workarounds. The backend
|
||||||
|
//! retains a root-confined target and atomic validator/dispatch boundary; the
|
||||||
|
//! engine owns recursive observation and standard host typed validation. No
|
||||||
|
//! listener is needed to use [`ProjectionView`]. [`HttpAdapter`] applies the
|
||||||
|
//! existing standard HTTP binding to a deployment-established principal view.
|
||||||
|
//!
|
||||||
|
//! Backends and Operations are trusted code, responsible for truthful effects,
|
||||||
|
//! public-only metadata and ongoing execution-context bounds. Policies must be
|
||||||
|
//! stable within a view; changing descriptor visibility requires new reference
|
||||||
|
//! lifetimes. See the crate README for implementer security and migration details.
|
||||||
|
//!
|
||||||
|
//! ```no_run
|
||||||
|
//! use wip_fs_server::{default_projection, AccessMode, ResourceLimits};
|
||||||
|
//! use wip_host::{ExecutionContext, ObjectProvider};
|
||||||
|
//! use wip_protocol::ObserveRequest;
|
||||||
|
//!
|
||||||
|
//! let engine = default_projection(std::path::Path::new("./public"),
|
||||||
|
//! AccessMode::ReadOnly, ResourceLimits::default())?;
|
||||||
|
//! let view = engine.view(&());
|
||||||
|
//! let context = ExecutionContext::new().with_resource_budget(128);
|
||||||
|
//! let tree = view.observe(&context, &ObserveRequest { path: "/".into(), depth: 1 })?;
|
||||||
|
//! assert_eq!(tree.object.name, "");
|
||||||
|
//! let object = view.observe_object_with_context(&context, "/")?;
|
||||||
|
//! assert!(object.interfaces.is_empty());
|
||||||
|
//! # Ok::<(), Box<dyn std::error::Error>>(())
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
//! The built-in HTTP listener is intentionally loopback-only. TLS termination,
|
//! The built-in HTTP listener is intentionally loopback-only. TLS termination,
|
||||||
//! certificates, authentication, and externally visible schemes are independent
|
//! certificates, authentication, and externally visible schemes are independent
|
||||||
//! deployment concerns rather than WIP transport profiles.
|
//! deployment concerns rather than WIP transport profiles.
|
||||||
|
|
||||||
#![deny(missing_docs)]
|
#![deny(missing_docs)]
|
||||||
|
|
||||||
|
mod defaults;
|
||||||
|
mod engine;
|
||||||
|
mod http_adapter;
|
||||||
mod interfaces;
|
mod interfaces;
|
||||||
mod projection;
|
mod projection;
|
||||||
mod server;
|
mod server;
|
||||||
@@ -21,7 +55,17 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
pub use interfaces::{FileRead, FileWrite, SymlinkInspect};
|
pub use defaults::{DefaultComposition, DefaultInterfaces, DefaultObject, default_projection};
|
||||||
|
pub use engine::{
|
||||||
|
BackendEntry, Composition, DefaultPolicy, EntryKind, FilesystemBackend, Projection,
|
||||||
|
ProjectionPolicy, ProjectionView, ResolvedEntry,
|
||||||
|
};
|
||||||
|
pub use http_adapter::HttpAdapter;
|
||||||
|
pub use interfaces::{
|
||||||
|
FileRead, FileReadDefinition, FileWrite, FileWriteDefinition, SymlinkInspect,
|
||||||
|
SymlinkInspectDefinition,
|
||||||
|
};
|
||||||
|
pub use projection::{LinuxBackend, LinuxEntry};
|
||||||
pub use server::{BoundServer, ServerBuildError, WipFsServer};
|
pub use server::{BoundServer, ServerBuildError, WipFsServer};
|
||||||
|
|
||||||
/// Filesystem capabilities published by regular files.
|
/// Filesystem capabilities published by regular files.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,26 +5,20 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use http::header::ALLOW;
|
|
||||||
use http::{Request, Response, StatusCode};
|
use http::{Request, Response, StatusCode};
|
||||||
use wip_host::{ExecutionContext, ObjectProvider};
|
use wip_host::ExecutionContext;
|
||||||
use wip_http::{
|
use wip_http::Endpoint;
|
||||||
CodecError, EncodeHttpError, Endpoint, RequestMetadataError, Route,
|
|
||||||
decode_call_operation_metadata, decode_fetch_interface_request, decode_observe_request,
|
|
||||||
encode_call_operation_response, encode_fetch_interface_response, encode_observe_response,
|
|
||||||
encode_protocol_error_response, validate_http_request,
|
|
||||||
};
|
|
||||||
use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction};
|
|
||||||
|
|
||||||
use crate::projection::Projection;
|
use crate::engine::Projection;
|
||||||
use crate::{ConfigError, ServerConfig};
|
use crate::http_adapter::HttpAdapter;
|
||||||
|
use crate::{ConfigError, DefaultComposition, DefaultPolicy, LinuxBackend, ServerConfig};
|
||||||
|
|
||||||
/// Filesystem projection and framework-neutral standard WIP request adapter.
|
/// Default Linux filesystem projection and bounded loopback HTTP listener.
|
||||||
pub struct WipFsServer {
|
pub struct WipFsServer {
|
||||||
config: ServerConfig,
|
config: ServerConfig,
|
||||||
endpoint: Endpoint,
|
endpoint: Endpoint,
|
||||||
effective_root: std::path::PathBuf,
|
effective_root: std::path::PathBuf,
|
||||||
projection: Arc<Projection>,
|
projection: Arc<Projection<LinuxBackend, DefaultComposition, DefaultPolicy>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WipFsServer {
|
impl WipFsServer {
|
||||||
@@ -32,8 +26,8 @@ impl WipFsServer {
|
|||||||
pub fn new(config: ServerConfig) -> Result<Self, ServerBuildError> {
|
pub fn new(config: ServerConfig) -> Result<Self, ServerBuildError> {
|
||||||
config.validate()?;
|
config.validate()?;
|
||||||
let endpoint = config.endpoint()?;
|
let endpoint = config.endpoint()?;
|
||||||
let projection = Projection::open(&config.root, config.access, config.limits)?;
|
let projection = crate::default_projection(&config.root, config.access, config.limits)?;
|
||||||
let effective_root = projection.effective_root()?;
|
let effective_root = projection.backend().effective_root()?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
config,
|
config,
|
||||||
endpoint,
|
endpoint,
|
||||||
@@ -67,25 +61,12 @@ impl WipFsServer {
|
|||||||
request: Request<Vec<u8>>,
|
request: Request<Vec<u8>>,
|
||||||
deadline: Instant,
|
deadline: Instant,
|
||||||
) -> Response<Vec<u8>> {
|
) -> Response<Vec<u8>> {
|
||||||
let Some(route) = self.route(request.uri()) else {
|
|
||||||
return empty_response(StatusCode::NOT_FOUND);
|
|
||||||
};
|
|
||||||
if let Err(error) = validate_http_request(&self.endpoint, route, &request) {
|
|
||||||
return self.metadata_error(error);
|
|
||||||
}
|
|
||||||
let context = ExecutionContext::new()
|
let context = ExecutionContext::new()
|
||||||
.with_deadline(deadline)
|
.with_deadline(deadline)
|
||||||
.with_resource_budget(u64::from(self.config.limits.max_observation_nodes));
|
.with_resource_budget(u64::from(self.config.limits.max_observation_nodes));
|
||||||
let response = match route {
|
let view = self.projection.view(&());
|
||||||
Route::Observe => self.observe(request.body(), &context),
|
HttpAdapter::new(&view, &self.endpoint, self.config.limits)
|
||||||
Route::FetchInterface => self.fetch_interface(request.body()),
|
.handle_http_with_context(request, &context)
|
||||||
Route::CallOperation => self.call_operation(request.body(), &context),
|
|
||||||
};
|
|
||||||
if Instant::now() >= deadline {
|
|
||||||
empty_response(StatusCode::GATEWAY_TIMEOUT)
|
|
||||||
} else {
|
|
||||||
response
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Binds the built-in bounded loopback HTTP listener.
|
/// Binds the built-in bounded loopback HTTP listener.
|
||||||
@@ -108,171 +89,6 @@ impl WipFsServer {
|
|||||||
listener: Arc::new(listener),
|
listener: Arc::new(listener),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn route(&self, uri: &http::Uri) -> Option<Route> {
|
|
||||||
if uri.query().is_some() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
[Route::Observe, Route::FetchInterface, Route::CallOperation]
|
|
||||||
.into_iter()
|
|
||||||
.find(|route| self.endpoint.route(*route).path() == uri.path())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn observe(&self, body: &[u8], context: &ExecutionContext) -> Response<Vec<u8>> {
|
|
||||||
let request = match decode_observe_request(body, self.config.limits.wire) {
|
|
||||||
Ok(request) => request,
|
|
||||||
Err(error) => return self.codec_error(ProtocolInteraction::Observe, error),
|
|
||||||
};
|
|
||||||
match self.projection.observe(context, &request) {
|
|
||||||
Ok(tree) => encode_observe_response(&request, &tree, self.config.limits.wire)
|
|
||||||
.unwrap_or_else(|error| {
|
|
||||||
self.read_encode_error(ProtocolInteraction::Observe, error)
|
|
||||||
}),
|
|
||||||
Err(error) => self.protocol_error(ProtocolInteraction::Observe, &error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fetch_interface(&self, body: &[u8]) -> Response<Vec<u8>> {
|
|
||||||
let request = match decode_fetch_interface_request(body, self.config.limits.wire) {
|
|
||||||
Ok(request) => request,
|
|
||||||
Err(error) => return self.codec_error(ProtocolInteraction::FetchInterface, error),
|
|
||||||
};
|
|
||||||
match self.projection.fetch_interface(&request) {
|
|
||||||
Ok(interface) => {
|
|
||||||
encode_fetch_interface_response(&request, &interface, self.config.limits.wire)
|
|
||||||
.unwrap_or_else(|error| {
|
|
||||||
self.read_encode_error(ProtocolInteraction::FetchInterface, error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Err(error) => self.protocol_error(ProtocolInteraction::FetchInterface, &error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn call_operation(&self, body: &[u8], context: &ExecutionContext) -> Response<Vec<u8>> {
|
|
||||||
let metadata = match decode_call_operation_metadata(body, self.config.limits.wire) {
|
|
||||||
Ok(metadata) => metadata,
|
|
||||||
Err(error) => return self.codec_error(ProtocolInteraction::CallOperation, error),
|
|
||||||
};
|
|
||||||
let may_commit = metadata.operation == "replace";
|
|
||||||
let decoded = std::sync::Mutex::new(None);
|
|
||||||
let execution = self.projection.call_operation_with_context_and_decoder(
|
|
||||||
context,
|
|
||||||
metadata.target.clone(),
|
|
||||||
metadata.interface.clone(),
|
|
||||||
metadata.operation.clone(),
|
|
||||||
|descriptor| {
|
|
||||||
let request = metadata.decode_request(body, descriptor, self.config.limits.wire)?;
|
|
||||||
let arguments = request.arguments.clone();
|
|
||||||
*decoded.lock().unwrap_or_else(|error| error.into_inner()) =
|
|
||||||
Some((request, descriptor.clone()));
|
|
||||||
Ok::<_, CodecError>(arguments)
|
|
||||||
},
|
|
||||||
);
|
|
||||||
match execution.map_err(|error| error.protocol_error()) {
|
|
||||||
Ok(result) => {
|
|
||||||
let Some((request, descriptor)) = decoded
|
|
||||||
.into_inner()
|
|
||||||
.unwrap_or_else(|error| error.into_inner())
|
|
||||||
else {
|
|
||||||
let error = ProtocolError {
|
|
||||||
code: if may_commit {
|
|
||||||
ProtocolErrorCode::OperationOutcomeUnknown
|
|
||||||
} else {
|
|
||||||
ProtocolErrorCode::Internal
|
|
||||||
},
|
|
||||||
message: "operation completed without response metadata".into(),
|
|
||||||
};
|
|
||||||
return self.protocol_error(ProtocolInteraction::CallOperation, &error);
|
|
||||||
};
|
|
||||||
encode_call_operation_response(
|
|
||||||
&request,
|
|
||||||
&descriptor,
|
|
||||||
&result,
|
|
||||||
self.config.limits.wire,
|
|
||||||
)
|
|
||||||
.unwrap_or_else(|encode_error| {
|
|
||||||
if matches!(
|
|
||||||
&encode_error,
|
|
||||||
EncodeHttpError::Codec(codec) if is_transport_limit(codec)
|
|
||||||
) {
|
|
||||||
return empty_response(StatusCode::INTERNAL_SERVER_ERROR);
|
|
||||||
}
|
|
||||||
if !may_commit {
|
|
||||||
return self
|
|
||||||
.read_encode_error(ProtocolInteraction::CallOperation, encode_error);
|
|
||||||
}
|
|
||||||
// Replacement may already be committed, so a response
|
|
||||||
// encoding failure cannot carry a non-commit guarantee.
|
|
||||||
let error = ProtocolError {
|
|
||||||
code: ProtocolErrorCode::OperationOutcomeUnknown,
|
|
||||||
message: "operation completed but its response could not be encoded".into(),
|
|
||||||
};
|
|
||||||
self.protocol_error(ProtocolInteraction::CallOperation, &error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Err(error) => self.protocol_error(ProtocolInteraction::CallOperation, &error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn metadata_error(&self, error: RequestMetadataError) -> Response<Vec<u8>> {
|
|
||||||
match error {
|
|
||||||
RequestMetadataError::NotFound => empty_response(StatusCode::NOT_FOUND),
|
|
||||||
error @ RequestMetadataError::MethodNotAllowed => Response::builder()
|
|
||||||
.status(error.status())
|
|
||||||
.header(
|
|
||||||
ALLOW,
|
|
||||||
error
|
|
||||||
.allow()
|
|
||||||
.expect("method-not-allowed metadata has an Allow value"),
|
|
||||||
)
|
|
||||||
.body(Vec::new())
|
|
||||||
.expect("static method response is valid"),
|
|
||||||
RequestMetadataError::UnsupportedMediaType => {
|
|
||||||
empty_response(StatusCode::UNSUPPORTED_MEDIA_TYPE)
|
|
||||||
}
|
|
||||||
RequestMetadataError::NotAcceptable => empty_response(StatusCode::NOT_ACCEPTABLE),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn codec_error(
|
|
||||||
&self,
|
|
||||||
interaction: ProtocolInteraction,
|
|
||||||
error: CodecError,
|
|
||||||
) -> Response<Vec<u8>> {
|
|
||||||
if is_transport_limit(&error) {
|
|
||||||
empty_response(StatusCode::PAYLOAD_TOO_LARGE)
|
|
||||||
} else {
|
|
||||||
self.protocol_error(
|
|
||||||
interaction,
|
|
||||||
&ProtocolError {
|
|
||||||
code: ProtocolErrorCode::InvalidRequest,
|
|
||||||
message: "malformed WIP request".into(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_encode_error(
|
|
||||||
&self,
|
|
||||||
interaction: ProtocolInteraction,
|
|
||||||
error: EncodeHttpError,
|
|
||||||
) -> Response<Vec<u8>> {
|
|
||||||
match error {
|
|
||||||
EncodeHttpError::Codec(codec) if is_transport_limit(&codec) => {
|
|
||||||
empty_response(StatusCode::INTERNAL_SERVER_ERROR)
|
|
||||||
}
|
|
||||||
_ => self.protocol_error(interaction, &internal_error()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn protocol_error(
|
|
||||||
&self,
|
|
||||||
interaction: ProtocolInteraction,
|
|
||||||
error: &ProtocolError,
|
|
||||||
) -> Response<Vec<u8>> {
|
|
||||||
encode_protocol_error_response(interaction, error, self.config.limits.wire)
|
|
||||||
.unwrap_or_else(|_| empty_response(StatusCode::INTERNAL_SERVER_ERROR))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bound real HTTP listener for a [`WipFsServer`].
|
/// Bound real HTTP listener for a [`WipFsServer`].
|
||||||
@@ -560,20 +376,6 @@ fn write_all_with_deadline(
|
|||||||
stream.flush()
|
stream.flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_transport_limit(error: &CodecError) -> bool {
|
|
||||||
matches!(
|
|
||||||
error,
|
|
||||||
CodecError::BodyTooLarge { .. } | CodecError::NestingTooDeep { .. }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn internal_error() -> ProtocolError {
|
|
||||||
ProtocolError {
|
|
||||||
code: ProtocolErrorCode::Internal,
|
|
||||||
message: "filesystem server failed".into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn empty_response(status: StatusCode) -> Response<Vec<u8>> {
|
fn empty_response(status: StatusCode) -> Response<Vec<u8>> {
|
||||||
Response::builder()
|
Response::builder()
|
||||||
.status(status)
|
.status(status)
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ fn real_listener_serves_three_core_interactions_and_write() {
|
|||||||
validator: file.validator.clone(),
|
validator: file.validator.clone(),
|
||||||
},
|
},
|
||||||
interface: InterfaceTarget {
|
interface: InterfaceTarget {
|
||||||
|
scope_ref: None,
|
||||||
reference: file.interfaces[0].clone(),
|
reference: file.interfaces[0].clone(),
|
||||||
validator: None,
|
validator: None,
|
||||||
},
|
},
|
||||||
@@ -141,6 +142,7 @@ fn real_listener_serves_three_core_interactions_and_write() {
|
|||||||
validator: file.validator,
|
validator: file.validator,
|
||||||
},
|
},
|
||||||
interface: InterfaceTarget {
|
interface: InterfaceTarget {
|
||||||
|
scope_ref: None,
|
||||||
reference: file.interfaces[1].clone(),
|
reference: file.interfaces[1].clone(),
|
||||||
validator: None,
|
validator: None,
|
||||||
},
|
},
|
||||||
@@ -412,7 +414,7 @@ fn call_argument_decoding_does_not_mask_missing_target() {
|
|||||||
let config = ServerConfig::for_root(temporary.path());
|
let config = ServerConfig::for_root(temporary.path());
|
||||||
let server = WipFsServer::new(config).expect("build server");
|
let server = WipFsServer::new(config).expect("build server");
|
||||||
let endpoint = Endpoint::parse("http://localhost/wip").expect("endpoint");
|
let endpoint = Endpoint::parse("http://localhost/wip").expect("endpoint");
|
||||||
let body = br#"{"target":{"path":"/missing"},"interface":{"reference":"unknown"},"operation":"missing","arguments":{"bytes":"not-base64"}}"#;
|
let body = br#"{"target":{"path":"/missing"},"interface":{"reference":{"scope":"/","name":"unknown"}},"operation":"missing","arguments":{"bytes":"not-base64"}}"#;
|
||||||
let request = http::Request::builder()
|
let request = http::Request::builder()
|
||||||
.method(http::Method::POST)
|
.method(http::Method::POST)
|
||||||
.uri(endpoint.route(Route::CallOperation).as_str())
|
.uri(endpoint.route(Route::CallOperation).as_str())
|
||||||
@@ -490,6 +492,7 @@ fn read_response_size_failure_is_transport_local() {
|
|||||||
validator: file.validator,
|
validator: file.validator,
|
||||||
},
|
},
|
||||||
interface: InterfaceTarget {
|
interface: InterfaceTarget {
|
||||||
|
scope_ref: None,
|
||||||
reference: file.interfaces[0].clone(),
|
reference: file.interfaces[0].clone(),
|
||||||
validator: None,
|
validator: None,
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,17 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wip-host"
|
name = "wip-host"
|
||||||
version = "0.1.0"
|
version.workspace = true
|
||||||
edition = "2024"
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
description = "Host-side object publication and operation dispatch for WIP"
|
description = "Host-side object publication and operation dispatch for WIP"
|
||||||
license = "MIT OR Apache-2.0"
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
readme = "README.md"
|
||||||
|
documentation = "https://docs.rs/wip-host"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
wip-interface = { path = "../wip-interface" }
|
wip-interface.workspace = true
|
||||||
wip-protocol = { path = "../wip-protocol" }
|
wip-protocol.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
trybuild = "1"
|
trybuild = "1"
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Hare
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# wip-host
|
||||||
|
|
||||||
|
Framework-independent Host publication and operation dispatch for the Web 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.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
wip-host = "0.1.0"
|
||||||
|
wip-interface = "0.1.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`.
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
See the [API documentation](https://docs.rs/wip-host) for publication, provider, execution-context, and effect-guarantee boundaries.
|
||||||
|
|
||||||
|
Licensed under either of Apache-2.0 or MIT at your option.
|
||||||
+242
-17
@@ -49,8 +49,8 @@ pub use wip_protocol::{
|
|||||||
Target,
|
Target,
|
||||||
};
|
};
|
||||||
use wip_protocol::{
|
use wip_protocol::{
|
||||||
FetchInterfaceRequest, FetchInterfaceResponse, InterfaceDescriptor, Object, ProtocolError,
|
FetchInterfaceRequest, FetchInterfaceResponse, InterfaceDescriptor, InterfaceReference, Object,
|
||||||
ProtocolErrorCode, ValidationError, Value,
|
ProtocolError, ProtocolErrorCode, ValidationError, Value,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Opaque bytes used as a conditional execution precondition.
|
/// Opaque bytes used as a conditional execution precondition.
|
||||||
@@ -192,11 +192,22 @@ impl ExecutionContext {
|
|||||||
pub struct InterfaceRef(String);
|
pub struct InterfaceRef(String);
|
||||||
|
|
||||||
impl InterfaceRef {
|
impl InterfaceRef {
|
||||||
/// Returns the opaque reference for a protocol transport.
|
/// Returns the legacy opaque registration reference.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn as_str(&self) -> &str {
|
pub fn as_str(&self) -> &str {
|
||||||
&self.0
|
&self.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Maps the legacy registration reference to a root-scoped protocol reference.
|
||||||
|
///
|
||||||
|
/// The opaque registration string is copied as the name without parsing it.
|
||||||
|
#[must_use]
|
||||||
|
pub fn protocol_reference(&self) -> InterfaceReference {
|
||||||
|
InterfaceReference {
|
||||||
|
scope: "/".into(),
|
||||||
|
name: self.0.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One atomic observation of an interface reference, descriptor, and validator.
|
/// One atomic observation of an interface reference, descriptor, and validator.
|
||||||
@@ -211,6 +222,17 @@ pub struct InterfaceObservation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl InterfaceObservation {
|
impl InterfaceObservation {
|
||||||
|
/// Restricts this effective view to visible operations and reachable types.
|
||||||
|
///
|
||||||
|
/// Registry artifacts are unchanged. When operations are removed, interface-
|
||||||
|
/// wide documentation is omitted because it may describe hidden operations.
|
||||||
|
/// Providers must use the same predicate for publication and fetch.
|
||||||
|
#[must_use]
|
||||||
|
pub fn project_operations(mut self, visible: impl Fn(&str) -> bool) -> Self {
|
||||||
|
restrict_descriptor(&mut self.descriptor, visible);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Converts this host observation to a validated canonical protocol response.
|
/// Converts this host observation to a validated canonical protocol response.
|
||||||
///
|
///
|
||||||
/// A Host-side response construction failure is returned as `Internal`; it is
|
/// A Host-side response construction failure is returned as `Internal`; it is
|
||||||
@@ -218,13 +240,14 @@ impl InterfaceObservation {
|
|||||||
/// local validation error.
|
/// local validation error.
|
||||||
pub fn protocol_response(&self) -> Result<FetchInterfaceResponse, ProtocolError> {
|
pub fn protocol_response(&self) -> Result<FetchInterfaceResponse, ProtocolError> {
|
||||||
let response = FetchInterfaceResponse {
|
let response = FetchInterfaceResponse {
|
||||||
interface: self.reference.0.clone(),
|
interface: self.reference.protocol_reference(),
|
||||||
descriptor: self.descriptor.clone(),
|
descriptor: self.descriptor.clone(),
|
||||||
|
scope_ref: None,
|
||||||
validator: self.validator.as_ref().map(|validator| validator.0.clone()),
|
validator: self.validator.as_ref().map(|validator| validator.0.clone()),
|
||||||
};
|
};
|
||||||
response
|
response
|
||||||
.validate_for(&FetchInterfaceRequest {
|
.validate_for(&FetchInterfaceRequest {
|
||||||
interface: self.reference.0.clone(),
|
interface: self.reference.protocol_reference(),
|
||||||
})
|
})
|
||||||
.map_err(|_| ProtocolError {
|
.map_err(|_| ProtocolError {
|
||||||
code: ProtocolErrorCode::Internal,
|
code: ProtocolErrorCode::Internal,
|
||||||
@@ -343,10 +366,16 @@ impl InterfaceRegistry {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves a transport-provided opaque reference string in this registry.
|
/// Resolves a structured protocol reference to a legacy registration token.
|
||||||
|
///
|
||||||
|
/// Legacy artifacts are published only at root scope. The name is looked up
|
||||||
|
/// verbatim; no scope or name parsing is performed.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn resolve(&self, reference: &str) -> Option<InterfaceRef> {
|
pub fn resolve(&self, reference: &InterfaceReference) -> Option<InterfaceRef> {
|
||||||
let reference = InterfaceRef(reference.to_owned());
|
if reference.scope != "/" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let reference = InterfaceRef(reference.name.clone());
|
||||||
self.artifacts.contains_key(&reference).then_some(reference)
|
self.artifacts.contains_key(&reference).then_some(reference)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,14 +385,20 @@ impl InterfaceRegistry {
|
|||||||
self.artifacts.get(reference).map(Self::observation)
|
self.artifacts.get(reference).map(Self::observation)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Implements canonical `fetch_interface` semantics for an opaque reference.
|
/// Implements canonical `fetch_interface` semantics for a structured reference.
|
||||||
///
|
///
|
||||||
/// Unknown and currently unpublished references map to `InterfaceNotFound`,
|
/// Malformed references map to `InvalidRequest`. Unknown and currently
|
||||||
/// not object membership errors or authorization failures.
|
/// unpublished references (including non-root scopes) map to `InterfaceNotFound`.
|
||||||
pub fn fetch_interface(&self, reference: &str) -> Result<InterfaceObservation, ProtocolError> {
|
pub fn fetch_interface(
|
||||||
let reference = InterfaceRef(reference.to_owned());
|
&self,
|
||||||
self.artifacts
|
reference: &InterfaceReference,
|
||||||
.get(&reference)
|
) -> Result<InterfaceObservation, ProtocolError> {
|
||||||
|
reference.validate().map_err(|error| ProtocolError {
|
||||||
|
code: ProtocolErrorCode::InvalidRequest,
|
||||||
|
message: error.to_string(),
|
||||||
|
})?;
|
||||||
|
self.resolve(reference)
|
||||||
|
.and_then(|reference| self.artifacts.get(&reference))
|
||||||
.map(Self::observation)
|
.map(Self::observation)
|
||||||
.ok_or_else(|| ProtocolError {
|
.ok_or_else(|| ProtocolError {
|
||||||
code: ProtocolErrorCode::InterfaceNotFound,
|
code: ProtocolErrorCode::InterfaceNotFound,
|
||||||
@@ -449,6 +484,60 @@ pub trait WipObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn restrict_descriptor(descriptor: &mut InterfaceDescriptor, visible: impl Fn(&str) -> bool) {
|
||||||
|
let count = descriptor.operations.len();
|
||||||
|
descriptor
|
||||||
|
.operations
|
||||||
|
.retain(|operation| visible(&operation.name));
|
||||||
|
if descriptor.operations.len() == count {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
descriptor.documentation = None;
|
||||||
|
let mut used = HashSet::new();
|
||||||
|
fn references(expression: &wip_protocol::TypeExpr, used: &mut HashSet<String>) {
|
||||||
|
use wip_protocol::TypeExpr;
|
||||||
|
match expression {
|
||||||
|
TypeExpr::Named { name } => {
|
||||||
|
used.insert(name.clone());
|
||||||
|
}
|
||||||
|
TypeExpr::Record { fields } => {
|
||||||
|
for field in fields {
|
||||||
|
references(&field.r#type, used);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TypeExpr::List { items } => references(items, used),
|
||||||
|
TypeExpr::Union { cases } => {
|
||||||
|
for case in cases {
|
||||||
|
if let Some(payload) = &case.payload {
|
||||||
|
references(payload, used);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for operation in &descriptor.operations {
|
||||||
|
for parameter in &operation.parameters {
|
||||||
|
references(¶meter.r#type, &mut used);
|
||||||
|
}
|
||||||
|
references(&operation.returns.r#type, &mut used);
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
let before = used.len();
|
||||||
|
for declaration in &descriptor.types {
|
||||||
|
if used.contains(&declaration.name) {
|
||||||
|
references(&declaration.definition, &mut used);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if before == used.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
descriptor
|
||||||
|
.types
|
||||||
|
.retain(|declaration| used.contains(&declaration.name));
|
||||||
|
}
|
||||||
|
|
||||||
type DispatchFn<T> = fn(&T, &str, BTreeMap<String, Value>) -> Result<Value, DispatchError>;
|
type DispatchFn<T> = fn(&T, &str, BTreeMap<String, Value>) -> Result<Value, DispatchError>;
|
||||||
|
|
||||||
struct BoundInterface<T> {
|
struct BoundInterface<T> {
|
||||||
@@ -502,6 +591,46 @@ impl<T: WipObject> PublishedObject<T> {
|
|||||||
Ok(self)
|
Ok(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Restricts a publication to an ordered interface/operation view.
|
||||||
|
///
|
||||||
|
/// This never adds implementations or changes codecs. The provider must use
|
||||||
|
/// the identical operation predicate for `fetch_interface`, and keep the
|
||||||
|
/// effective view stable for the lifetime of its references. Filtered
|
||||||
|
/// descriptors are scoped to that view, not mutations of registry artifacts.
|
||||||
|
pub fn project(
|
||||||
|
mut self,
|
||||||
|
interfaces: &[InterfaceRef],
|
||||||
|
visible: impl Fn(&InterfaceRef, &str) -> bool,
|
||||||
|
) -> Result<Self, PublicationError> {
|
||||||
|
let mut projected = Vec::with_capacity(interfaces.len());
|
||||||
|
for reference in interfaces {
|
||||||
|
if projected
|
||||||
|
.iter()
|
||||||
|
.any(|bound: &BoundInterface<T>| &bound.artifact.reference == reference)
|
||||||
|
{
|
||||||
|
return Err(PublicationError::DuplicateInterface(reference.clone()));
|
||||||
|
}
|
||||||
|
let Some(index) = self
|
||||||
|
.interfaces
|
||||||
|
.iter()
|
||||||
|
.position(|bound| &bound.artifact.reference == reference)
|
||||||
|
else {
|
||||||
|
return Err(PublicationError::UnexposedInterface(reference.clone()));
|
||||||
|
};
|
||||||
|
let mut bound = self.interfaces.remove(index);
|
||||||
|
let mut descriptor = bound.artifact.descriptor.clone();
|
||||||
|
restrict_descriptor(&mut descriptor, |operation| visible(reference, operation));
|
||||||
|
bound.artifact = Arc::new(InterfaceArtifact {
|
||||||
|
reference: reference.clone(),
|
||||||
|
descriptor,
|
||||||
|
validator: bound.artifact.validator.clone(),
|
||||||
|
});
|
||||||
|
projected.push(bound);
|
||||||
|
}
|
||||||
|
self.interfaces = projected;
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the host-private implementation without projecting its fields.
|
/// Returns the host-private implementation without projecting its fields.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn implementation(&self) -> &T {
|
pub fn implementation(&self) -> &T {
|
||||||
@@ -520,7 +649,7 @@ impl<T: WipObject> PublishedObject<T> {
|
|||||||
interfaces: self
|
interfaces: self
|
||||||
.interfaces
|
.interfaces
|
||||||
.iter()
|
.iter()
|
||||||
.map(|bound| bound.artifact.reference.0.clone())
|
.map(|bound| bound.artifact.reference.protocol_reference())
|
||||||
.collect(),
|
.collect(),
|
||||||
r#ref: self.implementation.reference().map(str::to_owned),
|
r#ref: self.implementation.reference().map(str::to_owned),
|
||||||
validator: self
|
validator: self
|
||||||
@@ -573,10 +702,17 @@ impl<T: WipObject> PublishedObject<T> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if interface.reference.validate_for_path(&target.path).is_err() {
|
||||||
|
return pre_dispatch_failure(
|
||||||
|
CallErrorKind::InterfaceMismatch,
|
||||||
|
"interface scope is not an ancestor of the target path",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let Some(bound) = self
|
let Some(bound) = self
|
||||||
.interfaces
|
.interfaces
|
||||||
.iter()
|
.iter()
|
||||||
.find(|bound| bound.artifact.reference.as_str() == interface.reference.as_str())
|
.find(|bound| bound.artifact.reference.protocol_reference() == interface.reference)
|
||||||
else {
|
else {
|
||||||
return pre_dispatch_failure(
|
return pre_dispatch_failure(
|
||||||
CallErrorKind::InterfaceMismatch,
|
CallErrorKind::InterfaceMismatch,
|
||||||
@@ -782,6 +918,15 @@ pub enum OperationAttempt {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl OperationAttempt {
|
impl OperationAttempt {
|
||||||
|
/// Rejects a provider precondition before typed implementation invocation.
|
||||||
|
///
|
||||||
|
/// Only use this before dispatch starts; post-invocation failures require
|
||||||
|
/// an explicit effect disposition. The diagnostic is intentionally generic.
|
||||||
|
#[must_use]
|
||||||
|
pub fn reject(kind: CallErrorKind) -> Self {
|
||||||
|
Self::NotStarted(CallError::new(kind, "provider precondition failed"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Marks a post-invocation failure as fully rolled back.
|
/// Marks a post-invocation failure as fully rolled back.
|
||||||
///
|
///
|
||||||
/// Successful attempts and pre-dispatch failures are returned unchanged.
|
/// Successful attempts and pre-dispatch failures are returned unchanged.
|
||||||
@@ -925,6 +1070,11 @@ pub trait ObjectProvider {
|
|||||||
) -> Result<CallResult, CallError> {
|
) -> Result<CallResult, CallError> {
|
||||||
wip_protocol::validate_path(&request.target.path)
|
wip_protocol::validate_path(&request.target.path)
|
||||||
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
||||||
|
request
|
||||||
|
.interface
|
||||||
|
.reference
|
||||||
|
.validate()
|
||||||
|
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
||||||
let path = request.target.path.clone();
|
let path = request.target.path.clone();
|
||||||
let execution = catch_unwind(AssertUnwindSafe(|| {
|
let execution = catch_unwind(AssertUnwindSafe(|| {
|
||||||
self.execute_operation(context, &path, |publication| {
|
self.execute_operation(context, &path, |publication| {
|
||||||
@@ -974,6 +1124,10 @@ pub trait ObjectProvider {
|
|||||||
) -> Result<CallResult, CallError> {
|
) -> Result<CallResult, CallError> {
|
||||||
wip_protocol::validate_path(&target.path)
|
wip_protocol::validate_path(&target.path)
|
||||||
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
||||||
|
interface
|
||||||
|
.reference
|
||||||
|
.validate()
|
||||||
|
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
||||||
let path = target.path.clone();
|
let path = target.path.clone();
|
||||||
let execution = catch_unwind(AssertUnwindSafe(|| {
|
let execution = catch_unwind(AssertUnwindSafe(|| {
|
||||||
self.execute_operation(context, &path, |publication| {
|
self.execute_operation(context, &path, |publication| {
|
||||||
@@ -1016,11 +1170,18 @@ pub trait ObjectProvider {
|
|||||||
pub enum PublicationError {
|
pub enum PublicationError {
|
||||||
/// The same opaque interface reference was exposed more than once.
|
/// The same opaque interface reference was exposed more than once.
|
||||||
DuplicateInterface(InterfaceRef),
|
DuplicateInterface(InterfaceRef),
|
||||||
|
/// Projection attempted to add an interface without an implementation.
|
||||||
|
UnexposedInterface(InterfaceRef),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Display for PublicationError {
|
impl Display for PublicationError {
|
||||||
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
|
Self::UnexposedInterface(reference) => write!(
|
||||||
|
formatter,
|
||||||
|
"interface `{}` has no implementation",
|
||||||
|
reference.as_str()
|
||||||
|
),
|
||||||
Self::DuplicateInterface(reference) => write!(
|
Self::DuplicateInterface(reference) => write!(
|
||||||
formatter,
|
formatter,
|
||||||
"interface `{}` is already exposed by this object",
|
"interface `{}` is already exposed by this object",
|
||||||
@@ -1032,6 +1193,70 @@ impl Display for PublicationError {
|
|||||||
|
|
||||||
impl Error for PublicationError {}
|
impl Error for PublicationError {}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod projection_tests {
|
||||||
|
use super::*;
|
||||||
|
use wip_protocol::{
|
||||||
|
Documentation, OperationDeclaration, ReturnDeclaration, TypeDeclaration, TypeExpr,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn operation_views_prune_hidden_types_and_documentation_without_mutating_registry() {
|
||||||
|
let named = |name: &str| TypeExpr::Named { name: name.into() };
|
||||||
|
let operation = |name: &str, returns| OperationDeclaration {
|
||||||
|
name: name.into(),
|
||||||
|
documentation: None,
|
||||||
|
parameters: Vec::new(),
|
||||||
|
returns: ReturnDeclaration {
|
||||||
|
documentation: None,
|
||||||
|
r#type: returns,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let declaration = |name: &str, definition| TypeDeclaration {
|
||||||
|
name: name.into(),
|
||||||
|
documentation: None,
|
||||||
|
definition,
|
||||||
|
};
|
||||||
|
let descriptor = InterfaceDescriptor {
|
||||||
|
format: "wip/1".into(),
|
||||||
|
documentation: Some(Documentation {
|
||||||
|
summary: "contains hidden operation details".into(),
|
||||||
|
details: None,
|
||||||
|
}),
|
||||||
|
operations: vec![
|
||||||
|
operation("visible", named("Public")),
|
||||||
|
operation("secret", named("Secret")),
|
||||||
|
],
|
||||||
|
types: vec![
|
||||||
|
declaration(
|
||||||
|
"Public",
|
||||||
|
TypeExpr::List {
|
||||||
|
items: Box::new(named("Shared")),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
declaration("Shared", TypeExpr::String),
|
||||||
|
declaration("Secret", TypeExpr::Bytes),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let mut projected = descriptor.clone();
|
||||||
|
restrict_descriptor(&mut projected, |operation| operation == "visible");
|
||||||
|
assert_eq!(projected.operations.len(), 1);
|
||||||
|
assert!(projected.documentation.is_none());
|
||||||
|
assert_eq!(
|
||||||
|
projected
|
||||||
|
.types
|
||||||
|
.iter()
|
||||||
|
.map(|declaration| declaration.name.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["Public", "Shared"]
|
||||||
|
);
|
||||||
|
assert_eq!(descriptor.types.len(), 3);
|
||||||
|
restrict_descriptor(&mut projected, |_| false);
|
||||||
|
assert!(projected.types.is_empty());
|
||||||
|
assert!(projected.operations.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Stable Host dispatch failure kind.
|
/// Stable Host dispatch failure kind.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum CallErrorKind {
|
pub enum CallErrorKind {
|
||||||
|
|||||||
+186
-11
@@ -14,6 +14,69 @@ use wip_interface::{
|
|||||||
};
|
};
|
||||||
use wip_protocol::ProtocolErrorCode;
|
use wip_protocol::ProtocolErrorCode;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn principal_projection_orders_subsets_and_rejects_unimplemented_or_duplicate_tokens() {
|
||||||
|
let mut registry = InterfaceRegistry::new();
|
||||||
|
let read = registry.register(ReaderDefinition, None).unwrap();
|
||||||
|
let write = registry.register(WriterDefinition, None).unwrap();
|
||||||
|
let hidden = registry.register(HiddenDefinition, None).unwrap();
|
||||||
|
let publication = || {
|
||||||
|
PublishedObject::new(Document::open("document", None, Rc::new(Cell::new(0))))
|
||||||
|
.expose(&read)
|
||||||
|
.unwrap()
|
||||||
|
.expose(&write)
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
let projected = publication()
|
||||||
|
.project(
|
||||||
|
&[write.reference().clone(), read.reference().clone()],
|
||||||
|
|_, operation| operation == "label",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
projected.protocol_object().interfaces,
|
||||||
|
vec![
|
||||||
|
write.reference().protocol_reference(),
|
||||||
|
read.reference().protocol_reference()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
publication()
|
||||||
|
.project(&[hidden.reference().clone()], |_, _| true)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
publication()
|
||||||
|
.project(
|
||||||
|
&[read.reference().clone(), read.reference().clone()],
|
||||||
|
|_, _| true
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
publication()
|
||||||
|
.project(&[], |_, _| true)
|
||||||
|
.unwrap()
|
||||||
|
.protocol_object()
|
||||||
|
.interfaces
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
let full = registry
|
||||||
|
.fetch_interface(&read.reference().protocol_reference())
|
||||||
|
.unwrap();
|
||||||
|
let filtered = full
|
||||||
|
.clone()
|
||||||
|
.project_operations(|operation| operation == "label");
|
||||||
|
assert_eq!(filtered.descriptor.operations.len(), 1);
|
||||||
|
assert_eq!(full.descriptor.operations.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
registry
|
||||||
|
.fetch_interface(&read.reference().protocol_reference())
|
||||||
|
.unwrap(),
|
||||||
|
full
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[interface]
|
#[interface]
|
||||||
pub trait Reader {
|
pub trait Reader {
|
||||||
/// Returns the reader-qualified label.
|
/// Returns the reader-qualified label.
|
||||||
@@ -422,7 +485,8 @@ fn request(
|
|||||||
validator: object_validator.map(<[u8]>::to_vec),
|
validator: object_validator.map(<[u8]>::to_vec),
|
||||||
},
|
},
|
||||||
interface: InterfaceTarget {
|
interface: InterfaceTarget {
|
||||||
reference: reference.as_str().to_owned(),
|
reference: reference.protocol_reference(),
|
||||||
|
scope_ref: None,
|
||||||
validator: interface_validator.map(<[u8]>::to_vec),
|
validator: interface_validator.map(<[u8]>::to_vec),
|
||||||
},
|
},
|
||||||
operation: operation.into(),
|
operation: operation.into(),
|
||||||
@@ -441,17 +505,22 @@ fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor()
|
|||||||
let reference = provider.reader.reference();
|
let reference = provider.reader.reference();
|
||||||
|
|
||||||
assert_ne!(reference.as_str(), "Reader");
|
assert_ne!(reference.as_str(), "Reader");
|
||||||
let observation = registry.fetch_interface(reference.as_str()).unwrap();
|
let observation = registry
|
||||||
|
.fetch_interface(&reference.protocol_reference())
|
||||||
|
.unwrap();
|
||||||
assert_eq!(observation.reference, *reference);
|
assert_eq!(observation.reference, *reference);
|
||||||
assert_eq!(observation.descriptor, ReaderDefinition::descriptor());
|
assert_eq!(observation.descriptor, ReaderDefinition::descriptor());
|
||||||
assert_eq!(observation.validator, Some(Validator::new(b"schema-v1")));
|
assert_eq!(observation.validator, Some(Validator::new(b"schema-v1")));
|
||||||
let response = observation.protocol_response().unwrap();
|
let response = observation.protocol_response().unwrap();
|
||||||
assert_eq!(response.interface, reference.as_str());
|
assert_eq!(response.interface, reference.protocol_reference());
|
||||||
|
assert_eq!(response.scope_ref, None);
|
||||||
assert_eq!(response.descriptor, ReaderDefinition::descriptor());
|
assert_eq!(response.descriptor, ReaderDefinition::descriptor());
|
||||||
assert_eq!(response.validator.as_deref(), Some(b"schema-v1".as_slice()));
|
assert_eq!(response.validator.as_deref(), Some(b"schema-v1".as_slice()));
|
||||||
response.descriptor.validate().unwrap();
|
response.descriptor.validate().unwrap();
|
||||||
|
|
||||||
let mut invalid_observation = registry.fetch_interface(reference.as_str()).unwrap();
|
let mut invalid_observation = registry
|
||||||
|
.fetch_interface(&reference.protocol_reference())
|
||||||
|
.unwrap();
|
||||||
let duplicate_operation = invalid_observation.descriptor.operations[0].clone();
|
let duplicate_operation = invalid_observation.descriptor.operations[0].clone();
|
||||||
invalid_observation
|
invalid_observation
|
||||||
.descriptor
|
.descriptor
|
||||||
@@ -460,7 +529,12 @@ fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor()
|
|||||||
let invalid_response = invalid_observation.protocol_response().unwrap_err();
|
let invalid_response = invalid_observation.protocol_response().unwrap_err();
|
||||||
assert_eq!(invalid_response.code, ProtocolErrorCode::Internal);
|
assert_eq!(invalid_response.code, ProtocolErrorCode::Internal);
|
||||||
|
|
||||||
let error = registry.fetch_interface("unknown").unwrap_err();
|
let error = registry
|
||||||
|
.fetch_interface(&wip_protocol::InterfaceReference {
|
||||||
|
scope: "/".into(),
|
||||||
|
name: "unknown".into(),
|
||||||
|
})
|
||||||
|
.unwrap_err();
|
||||||
assert_eq!(error.code, ProtocolErrorCode::InterfaceNotFound);
|
assert_eq!(error.code, ProtocolErrorCode::InterfaceNotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,8 +550,8 @@ fn canonical_object_projection_supports_optional_metadata_and_ordered_sets() {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
object.interfaces,
|
object.interfaces,
|
||||||
vec![
|
vec![
|
||||||
provider.writer.reference().as_str().to_owned(),
|
provider.writer.reference().protocol_reference(),
|
||||||
provider.reader.reference().as_str().to_owned(),
|
provider.reader.reference().protocol_reference(),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert!(!format!("{object:?}").contains("secret-handle"));
|
assert!(!format!("{object:?}").contains("secret-handle"));
|
||||||
@@ -758,7 +832,8 @@ fn descriptor_guided_decoder_runs_inside_canonical_resolution_boundary() {
|
|||||||
validator: Some(b"stale".to_vec()),
|
validator: Some(b"stale".to_vec()),
|
||||||
},
|
},
|
||||||
InterfaceTarget {
|
InterfaceTarget {
|
||||||
reference: hidden.reference().as_str().into(),
|
reference: hidden.reference().protocol_reference(),
|
||||||
|
scope_ref: None,
|
||||||
validator: None,
|
validator: None,
|
||||||
},
|
},
|
||||||
"missing".into(),
|
"missing".into(),
|
||||||
@@ -776,7 +851,8 @@ fn descriptor_guided_decoder_runs_inside_canonical_resolution_boundary() {
|
|||||||
validator: Some(b"stale".to_vec()),
|
validator: Some(b"stale".to_vec()),
|
||||||
},
|
},
|
||||||
InterfaceTarget {
|
InterfaceTarget {
|
||||||
reference: hidden.reference().as_str().into(),
|
reference: hidden.reference().protocol_reference(),
|
||||||
|
scope_ref: None,
|
||||||
validator: None,
|
validator: None,
|
||||||
},
|
},
|
||||||
"hidden".into(),
|
"hidden".into(),
|
||||||
@@ -794,7 +870,8 @@ fn descriptor_guided_decoder_runs_inside_canonical_resolution_boundary() {
|
|||||||
validator: Some(b"object-v1".to_vec()),
|
validator: Some(b"object-v1".to_vec()),
|
||||||
},
|
},
|
||||||
InterfaceTarget {
|
InterfaceTarget {
|
||||||
reference: hidden.reference().as_str().into(),
|
reference: hidden.reference().protocol_reference(),
|
||||||
|
scope_ref: None,
|
||||||
validator: None,
|
validator: None,
|
||||||
},
|
},
|
||||||
"hidden".into(),
|
"hidden".into(),
|
||||||
@@ -812,7 +889,8 @@ fn descriptor_guided_decoder_runs_inside_canonical_resolution_boundary() {
|
|||||||
validator: Some(b"object-v1".to_vec()),
|
validator: Some(b"object-v1".to_vec()),
|
||||||
},
|
},
|
||||||
InterfaceTarget {
|
InterfaceTarget {
|
||||||
reference: provider.reader.reference().as_str().into(),
|
reference: provider.reader.reference().protocol_reference(),
|
||||||
|
scope_ref: None,
|
||||||
validator: Some(b"schema-v1".to_vec()),
|
validator: Some(b"schema-v1".to_vec()),
|
||||||
},
|
},
|
||||||
"add".into(),
|
"add".into(),
|
||||||
@@ -1066,3 +1144,100 @@ fn execution_context_is_propagated_and_preflight_prevents_invocation() {
|
|||||||
);
|
);
|
||||||
assert_eq!(provider.calls.get(), calls);
|
assert_eq!(provider.calls.get(), calls);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_registry_boundaries_copy_names_verbatim_and_match_exact_scope() {
|
||||||
|
let (registry, provider, _) = registry_and_provider();
|
||||||
|
let legacy = provider.writer.reference();
|
||||||
|
let reference = legacy.protocol_reference();
|
||||||
|
assert_eq!(reference.scope, "/");
|
||||||
|
assert_eq!(reference.name, legacy.as_str());
|
||||||
|
// The registry's colon-delimited name remains opaque, not parsed.
|
||||||
|
assert!(reference.name.starts_with("wip-interface:"));
|
||||||
|
assert_eq!(registry.resolve(&reference).as_ref(), Some(legacy));
|
||||||
|
let nested = wip_protocol::InterfaceReference {
|
||||||
|
scope: "/open".into(),
|
||||||
|
name: reference.name.clone(),
|
||||||
|
};
|
||||||
|
assert!(registry.resolve(&nested).is_none());
|
||||||
|
assert_eq!(
|
||||||
|
registry.fetch_interface(&nested).unwrap_err().code,
|
||||||
|
ProtocolErrorCode::InterfaceNotFound
|
||||||
|
);
|
||||||
|
let malformed = wip_protocol::InterfaceReference {
|
||||||
|
scope: "relative".into(),
|
||||||
|
name: reference.name,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
registry.fetch_interface(&malformed).unwrap_err().code,
|
||||||
|
ProtocolErrorCode::InvalidRequest
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn call_reference_shape_precedes_resolution_and_scope_membership_is_exact() {
|
||||||
|
let (_, provider, _) = registry_and_provider();
|
||||||
|
for (scope, name) in [("relative", "valid"), ("/", ""), ("/bad/", "valid")] {
|
||||||
|
let mut call = request(
|
||||||
|
"/open",
|
||||||
|
None,
|
||||||
|
provider.writer.reference(),
|
||||||
|
None,
|
||||||
|
"label",
|
||||||
|
BTreeMap::new(),
|
||||||
|
);
|
||||||
|
call.interface.reference = wip_protocol::InterfaceReference {
|
||||||
|
scope: scope.into(),
|
||||||
|
name: name.into(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
provider.call_operation(call.clone()).unwrap_err().kind(),
|
||||||
|
CallErrorKind::InvalidRequest
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.call_operation_with_context_and_decoder(
|
||||||
|
&ExecutionContext::new(),
|
||||||
|
call.target,
|
||||||
|
call.interface,
|
||||||
|
call.operation,
|
||||||
|
|_| Ok::<_, ()>(BTreeMap::new()),
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
CallErrorKind::InvalidRequest
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(provider.resolutions.get(), 0);
|
||||||
|
// /other is outside the target scope; /open is self but has no legacy artifact.
|
||||||
|
for scope in ["/other", "/open"] {
|
||||||
|
let mut call = request(
|
||||||
|
"/open",
|
||||||
|
None,
|
||||||
|
provider.writer.reference(),
|
||||||
|
None,
|
||||||
|
"label",
|
||||||
|
BTreeMap::new(),
|
||||||
|
);
|
||||||
|
call.interface.reference.scope = scope.into();
|
||||||
|
assert_eq!(
|
||||||
|
provider.call_operation(call.clone()).unwrap_err().kind(),
|
||||||
|
CallErrorKind::InterfaceMismatch
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.call_operation_with_context_and_decoder(
|
||||||
|
&ExecutionContext::new(),
|
||||||
|
call.target,
|
||||||
|
call.interface,
|
||||||
|
call.operation,
|
||||||
|
|_| -> Result<BTreeMap<String, Value>, ()> { panic!("must not decode") },
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
CallErrorKind::InterfaceMismatch
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(provider.calls.get(), 0);
|
||||||
|
assert_eq!(provider.authorization_checks.get(), 0);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wip-http"
|
name = "wip-http"
|
||||||
version = "0.1.0"
|
version.workspace = true
|
||||||
edition = "2024"
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
description = "Framework-independent standard WIP over HTTP v1 binding"
|
description = "Framework-independent standard WIP over HTTP v1 binding"
|
||||||
license = "MIT OR Apache-2.0"
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
readme = "README.md"
|
||||||
|
documentation = "https://docs.rs/wip-http"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
@@ -11,4 +15,4 @@ http = "1"
|
|||||||
serde = "1"
|
serde = "1"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
url = "2"
|
url = "2"
|
||||||
wip-protocol = { path = "../wip-protocol" }
|
wip-protocol.workspace = true
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Hare
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# wip-http
|
||||||
|
|
||||||
|
Framework-independent WIP over HTTP v1 endpoint, route, JSON codec, and response-classification support.
|
||||||
|
|
||||||
|
The crate maps the three WIP Core interactions to fixed HTTP routes and strict UTF-8 JSON representations. It constructs and validates `http` request and response values but deliberately performs no network I/O and does not define TLS, authentication, proxy, or connection policy.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
wip-http = "0.1.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wip_http::wip_protocol::ObserveRequest;
|
||||||
|
use wip_http::{Endpoint, Limits, encode_observe_request};
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let endpoint = Endpoint::parse("https://example.test/wip")?;
|
||||||
|
let limits = Limits::new(64 * 1024, 1024 * 1024, 64)?;
|
||||||
|
let request = encode_observe_request(
|
||||||
|
&endpoint,
|
||||||
|
&ObserveRequest { path: "/".into(), depth: 0 },
|
||||||
|
limits,
|
||||||
|
)?;
|
||||||
|
assert_eq!(request.uri(), "https://example.test/wip/v1/observe");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Interface references and scope identity
|
||||||
|
|
||||||
|
Every interface reference is encoded as an exact JSON object with required
|
||||||
|
`scope` and `name` strings, for example `{"scope":"/github","name":"issue.read"}`.
|
||||||
|
This applies to `Object.interfaces` elements, fetch request/response `interface`,
|
||||||
|
and call `interface.reference`. Scope must be a canonical absolute Worldspace
|
||||||
|
path and name must be nonempty. Legacy opaque strings, short-form strings,
|
||||||
|
relative scopes, missing fields, and extra members are rejected. Unicode, `::`,
|
||||||
|
and `#` in names remain literal data; comparison uses decoded field values,
|
||||||
|
independent of JSON member order and escapes. HTTP endpoint prefixes do not
|
||||||
|
change Worldspace scope.
|
||||||
|
|
||||||
|
Fetch responses and call interface targets additionally carry optional
|
||||||
|
`scope_ref`, an opaque string copied from the scope Object's `ref` observation.
|
||||||
|
It is not base64 and is not part of interface identity. Absent values are omitted;
|
||||||
|
null and nonstring values are rejected. Descriptor-independent call metadata
|
||||||
|
validates reference shape, but leaves scope ancestry and identity preconditions
|
||||||
|
to the Host's `InterfaceMismatch` check, preserving protocol error precedence.
|
||||||
|
Invalid success responses remain client-local `InvalidResponse` errors; protocol
|
||||||
|
error envelopes and transport failures retain their existing classification.
|
||||||
|
|
||||||
|
See the [API documentation](https://docs.rs/wip-http) for codec and failure-boundary details.
|
||||||
|
|
||||||
|
Licensed under either of Apache-2.0 or MIT at your option.
|
||||||
@@ -296,11 +296,14 @@ pub fn decode_observe_request(body: &[u8], limits: Limits) -> Result<ObserveRequ
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Encodes a `fetch_interface` HTTP request.
|
/// Encodes a `fetch_interface` HTTP request.
|
||||||
|
///
|
||||||
|
/// Validates reference shape without requiring a target Object path.
|
||||||
pub fn encode_fetch_interface_request(
|
pub fn encode_fetch_interface_request(
|
||||||
endpoint: &Endpoint,
|
endpoint: &Endpoint,
|
||||||
value: &FetchInterfaceRequest,
|
value: &FetchInterfaceRequest,
|
||||||
limits: Limits,
|
limits: Limits,
|
||||||
) -> Result<Request<Vec<u8>>, EncodeHttpError> {
|
) -> Result<Request<Vec<u8>>, EncodeHttpError> {
|
||||||
|
value.interface.validate().map_err(CodecError::from)?;
|
||||||
let body = json::serialize(
|
let body = json::serialize(
|
||||||
&json::fetch_interface_request_to_json(value),
|
&json::fetch_interface_request_to_json(value),
|
||||||
BodyKind::Request,
|
BodyKind::Request,
|
||||||
@@ -358,6 +361,10 @@ impl CallOperationMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Decodes descriptor-independent `call_operation` routing metadata.
|
/// Decodes descriptor-independent `call_operation` routing metadata.
|
||||||
|
///
|
||||||
|
/// Reference shape is validated, but scope ancestry and `scope_ref` identity
|
||||||
|
/// are left to Host resolution so `NotFound`, validator failures, and
|
||||||
|
/// `InterfaceMismatch` retain their protocol precedence.
|
||||||
pub fn decode_call_operation_metadata(
|
pub fn decode_call_operation_metadata(
|
||||||
body: &[u8],
|
body: &[u8],
|
||||||
limits: Limits,
|
limits: Limits,
|
||||||
|
|||||||
+49
-14
@@ -8,9 +8,9 @@ use serde_json::{Map, Number, Value as Json};
|
|||||||
use wip_protocol::{
|
use wip_protocol::{
|
||||||
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
|
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
|
||||||
FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor,
|
FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor,
|
||||||
InterfaceTarget, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, Object, ObjectObservation, ObserveRequest,
|
InterfaceReference, InterfaceTarget, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, Object,
|
||||||
ObserveResponse, OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target,
|
ObjectObservation, ObserveRequest, ObserveResponse, OperationDeclaration, ParameterDeclaration,
|
||||||
TypeDeclaration, TypeExpr, UnionCase, Value,
|
ReturnDeclaration, Target, TypeDeclaration, TypeExpr, UnionCase, Value,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{BodyKind, CodecError, Limits};
|
use crate::{BodyKind, CodecError, Limits};
|
||||||
@@ -285,8 +285,25 @@ pub(crate) fn observe_request_from_json(value: &Json) -> Result<ObserveRequest,
|
|||||||
Ok(request)
|
Ok(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn interface_reference_to_json(reference: &InterfaceReference) -> Json {
|
||||||
|
serde_json::json!({"scope": reference.scope, "name": reference.name})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interface_reference_from_json(
|
||||||
|
value: &Json,
|
||||||
|
field: &str,
|
||||||
|
) -> Result<InterfaceReference, CodecError> {
|
||||||
|
let fields = exact_object(value, field, &["scope", "name"])?;
|
||||||
|
let reference = InterfaceReference {
|
||||||
|
scope: string(required(fields, "scope")?, &qualified(field, "scope"))?,
|
||||||
|
name: string(required(fields, "name")?, &qualified(field, "name"))?,
|
||||||
|
};
|
||||||
|
reference.validate()?;
|
||||||
|
Ok(reference)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn fetch_interface_request_to_json(request: &FetchInterfaceRequest) -> Json {
|
pub(crate) fn fetch_interface_request_to_json(request: &FetchInterfaceRequest) -> Json {
|
||||||
serde_json::json!({"interface": request.interface})
|
serde_json::json!({"interface": interface_reference_to_json(&request.interface)})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn fetch_interface_request_from_json(
|
pub(crate) fn fetch_interface_request_from_json(
|
||||||
@@ -294,7 +311,7 @@ pub(crate) fn fetch_interface_request_from_json(
|
|||||||
) -> Result<FetchInterfaceRequest, CodecError> {
|
) -> Result<FetchInterfaceRequest, CodecError> {
|
||||||
let fields = exact_object(value, "request", &["interface"])?;
|
let fields = exact_object(value, "request", &["interface"])?;
|
||||||
Ok(FetchInterfaceRequest {
|
Ok(FetchInterfaceRequest {
|
||||||
interface: string(required(fields, "interface")?, "interface")?,
|
interface: interface_reference_from_json(required(fields, "interface")?, "interface")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,8 +325,7 @@ pub(crate) fn object_to_json(object: &Object) -> Json {
|
|||||||
object
|
object
|
||||||
.interfaces
|
.interfaces
|
||||||
.iter()
|
.iter()
|
||||||
.cloned()
|
.map(interface_reference_to_json)
|
||||||
.map(Json::String)
|
|
||||||
.collect(),
|
.collect(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -327,7 +343,7 @@ pub(crate) fn object_from_json(value: &Json, field: &str) -> Result<Object, Code
|
|||||||
let interfaces = array(required(fields, "interfaces")?, "interfaces")?
|
let interfaces = array(required(fields, "interfaces")?, "interfaces")?
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(index, value)| string(value, &format!("interfaces[{index}]")))
|
.map(|(index, value)| interface_reference_from_json(value, &format!("interfaces[{index}]")))
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
let object = Object {
|
let object = Object {
|
||||||
name: string(required(fields, "name")?, "name")?,
|
name: string(required(fields, "name")?, "name")?,
|
||||||
@@ -745,7 +761,11 @@ pub(crate) fn descriptor_from_json(
|
|||||||
|
|
||||||
pub(crate) fn fetch_interface_response_to_json(response: &FetchInterfaceResponse) -> Json {
|
pub(crate) fn fetch_interface_response_to_json(response: &FetchInterfaceResponse) -> Json {
|
||||||
let mut fields = Map::new();
|
let mut fields = Map::new();
|
||||||
fields.insert("interface".into(), Json::String(response.interface.clone()));
|
fields.insert(
|
||||||
|
"interface".into(),
|
||||||
|
interface_reference_to_json(&response.interface),
|
||||||
|
);
|
||||||
|
insert_optional_string(&mut fields, "scope_ref", &response.scope_ref);
|
||||||
fields.insert(
|
fields.insert(
|
||||||
"descriptor".into(),
|
"descriptor".into(),
|
||||||
descriptor_to_json(&response.descriptor),
|
descriptor_to_json(&response.descriptor),
|
||||||
@@ -757,8 +777,15 @@ pub(crate) fn fetch_interface_response_to_json(response: &FetchInterfaceResponse
|
|||||||
pub(crate) fn fetch_interface_response_from_json(
|
pub(crate) fn fetch_interface_response_from_json(
|
||||||
value: &Json,
|
value: &Json,
|
||||||
) -> Result<FetchInterfaceResponse, CodecError> {
|
) -> Result<FetchInterfaceResponse, CodecError> {
|
||||||
let fields = exact_object(value, "response", &["interface", "descriptor", "validator"])?;
|
let fields = exact_object(
|
||||||
let interface = string(required(fields, "interface")?, "interface")?;
|
value,
|
||||||
|
"response",
|
||||||
|
&["interface", "scope_ref", "descriptor", "validator"],
|
||||||
|
)?;
|
||||||
|
let interface = interface_reference_from_json(required(fields, "interface")?, "interface")?;
|
||||||
|
let scope_ref = optional(fields, "scope_ref")?
|
||||||
|
.map(|value| string(value, "scope_ref"))
|
||||||
|
.transpose()?;
|
||||||
let validator = optional(fields, "validator")?
|
let validator = optional(fields, "validator")?
|
||||||
.map(|value| base64_decode(value, "validator"))
|
.map(|value| base64_decode(value, "validator"))
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
@@ -780,6 +807,7 @@ pub(crate) fn fetch_interface_response_from_json(
|
|||||||
};
|
};
|
||||||
Ok(FetchInterfaceResponse {
|
Ok(FetchInterfaceResponse {
|
||||||
interface,
|
interface,
|
||||||
|
scope_ref,
|
||||||
descriptor,
|
descriptor,
|
||||||
validator,
|
validator,
|
||||||
})
|
})
|
||||||
@@ -1121,18 +1149,25 @@ fn target_from_json(value: &Json, field: &str) -> Result<Target, CodecError> {
|
|||||||
|
|
||||||
fn interface_target_to_json(target: &InterfaceTarget) -> Json {
|
fn interface_target_to_json(target: &InterfaceTarget) -> Json {
|
||||||
let mut fields = Map::new();
|
let mut fields = Map::new();
|
||||||
fields.insert("reference".into(), Json::String(target.reference.clone()));
|
fields.insert(
|
||||||
|
"reference".into(),
|
||||||
|
interface_reference_to_json(&target.reference),
|
||||||
|
);
|
||||||
|
insert_optional_string(&mut fields, "scope_ref", &target.scope_ref);
|
||||||
insert_optional_bytes(&mut fields, "validator", &target.validator);
|
insert_optional_bytes(&mut fields, "validator", &target.validator);
|
||||||
Json::Object(fields)
|
Json::Object(fields)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn interface_target_from_json(value: &Json, field: &str) -> Result<InterfaceTarget, CodecError> {
|
fn interface_target_from_json(value: &Json, field: &str) -> Result<InterfaceTarget, CodecError> {
|
||||||
let fields = exact_object(value, field, &["reference", "validator"])?;
|
let fields = exact_object(value, field, &["reference", "scope_ref", "validator"])?;
|
||||||
Ok(InterfaceTarget {
|
Ok(InterfaceTarget {
|
||||||
reference: string(
|
reference: interface_reference_from_json(
|
||||||
required(fields, "reference")?,
|
required(fields, "reference")?,
|
||||||
&qualified(field, "reference"),
|
&qualified(field, "reference"),
|
||||||
)?,
|
)?,
|
||||||
|
scope_ref: optional(fields, "scope_ref")?
|
||||||
|
.map(|value| string(value, &qualified(field, "scope_ref")))
|
||||||
|
.transpose()?,
|
||||||
validator: optional(fields, "validator")?
|
validator: optional(fields, "validator")?
|
||||||
.map(|value| base64_decode(value, &qualified(field, "validator")))
|
.map(|value| base64_decode(value, &qualified(field, "validator")))
|
||||||
.transpose()?,
|
.transpose()?,
|
||||||
|
|||||||
@@ -12,6 +12,14 @@
|
|||||||
//! encoded in JSON bodies using canonical RFC 4648 Section 4 base64; this crate
|
//! encoded in JSON bodies using canonical RFC 4648 Section 4 base64; this crate
|
||||||
//! does not infer WIP validator semantics from `ETag` or `If-Match`.
|
//! does not infer WIP validator semantics from `ETag` or `If-Match`.
|
||||||
//!
|
//!
|
||||||
|
//! Interface references use exact JSON objects with required `scope` and `name`
|
||||||
|
//! strings in every position. Legacy strings and display shorthand are rejected.
|
||||||
|
//! Scope is a canonical absolute Worldspace path, not an HTTP endpoint prefix.
|
||||||
|
//! Optional `scope_ref` metadata on fetch responses and call interface targets is
|
||||||
|
//! an opaque string, never base64; absent fields are omitted and null is rejected.
|
||||||
|
//! Call metadata validates reference shape without resolving scope ancestry or
|
||||||
|
//! identity, which remain Host checks with `InterfaceMismatch` precedence.
|
||||||
|
//!
|
||||||
//! ```
|
//! ```
|
||||||
//! use wip_http::{Endpoint, Limits, encode_observe_request};
|
//! use wip_http::{Endpoint, Limits, encode_observe_request};
|
||||||
//! use wip_protocol::ObserveRequest;
|
//! use wip_protocol::ObserveRequest;
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ use wip_http::http::StatusCode;
|
|||||||
use wip_http::wip_protocol::{
|
use wip_http::wip_protocol::{
|
||||||
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
|
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
|
||||||
FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor,
|
FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor,
|
||||||
InterfaceTarget, MAX_SAFE_INTEGER, Object, ObjectObservation, ObserveRequest,
|
InterfaceReference, InterfaceTarget, MAX_SAFE_INTEGER, Object, ObjectObservation,
|
||||||
OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target, TypeDeclaration,
|
ObserveRequest, OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target,
|
||||||
TypeExpr, UnionCase, Value,
|
TypeDeclaration, TypeExpr, UnionCase, Value,
|
||||||
};
|
};
|
||||||
use wip_http::{
|
use wip_http::{
|
||||||
ClientResponseError, CodecError, DecodedResponse, Endpoint, InvalidResponseKind, Limits,
|
ClientResponseError, CodecError, DecodedResponse, Endpoint, InvalidResponseKind, Limits,
|
||||||
@@ -25,11 +25,18 @@ fn endpoint() -> Endpoint {
|
|||||||
Endpoint::parse("https://example.test/wip").unwrap()
|
Endpoint::parse("https://example.test/wip").unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reference(scope: &str, name: &str) -> InterfaceReference {
|
||||||
|
InterfaceReference {
|
||||||
|
scope: scope.into(),
|
||||||
|
name: name.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn object(name: &str, validator: &[u8]) -> Object {
|
fn object(name: &str, validator: &[u8]) -> Object {
|
||||||
Object {
|
Object {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
description: Some("description".into()),
|
description: Some("description".into()),
|
||||||
interfaces: vec!["interface-v1".into()],
|
interfaces: vec![reference("/", "interface-v1")],
|
||||||
r#ref: Some(format!("object:{name}")),
|
r#ref: Some(format!("object:{name}")),
|
||||||
validator: Some(validator.to_vec()),
|
validator: Some(validator.to_vec()),
|
||||||
}
|
}
|
||||||
@@ -152,7 +159,8 @@ fn call_request() -> CallOperationRequest {
|
|||||||
validator: Some(vec![0, 1, 2, 255]),
|
validator: Some(vec![0, 1, 2, 255]),
|
||||||
},
|
},
|
||||||
interface: InterfaceTarget {
|
interface: InterfaceTarget {
|
||||||
reference: "interface-v1".into(),
|
reference: reference("/", "interface-v1"),
|
||||||
|
scope_ref: None,
|
||||||
validator: Some(vec![9, 8, 7]),
|
validator: Some(vec![9, 8, 7]),
|
||||||
},
|
},
|
||||||
operation: "everything".into(),
|
operation: "everything".into(),
|
||||||
@@ -206,7 +214,7 @@ fn every_single_interaction_request_accepts_json() {
|
|||||||
let fetch_interface = encode_fetch_interface_request(
|
let fetch_interface = encode_fetch_interface_request(
|
||||||
&endpoint(),
|
&endpoint(),
|
||||||
&FetchInterfaceRequest {
|
&FetchInterfaceRequest {
|
||||||
interface: "interface-v1".into(),
|
interface: reference("/", "interface-v1"),
|
||||||
},
|
},
|
||||||
limits(),
|
limits(),
|
||||||
)
|
)
|
||||||
@@ -286,7 +294,7 @@ fn observe_and_interface_round_trip_nested_logical_models() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let interface_request = FetchInterfaceRequest {
|
let interface_request = FetchInterfaceRequest {
|
||||||
interface: "interface-v1".into(),
|
interface: reference("/", "interface-v1"),
|
||||||
};
|
};
|
||||||
let encoded_request =
|
let encoded_request =
|
||||||
encode_fetch_interface_request(&endpoint(), &interface_request, limits()).unwrap();
|
encode_fetch_interface_request(&endpoint(), &interface_request, limits()).unwrap();
|
||||||
@@ -296,6 +304,7 @@ fn observe_and_interface_round_trip_nested_logical_models() {
|
|||||||
);
|
);
|
||||||
let interface = FetchInterfaceResponse {
|
let interface = FetchInterfaceResponse {
|
||||||
interface: interface_request.interface.clone(),
|
interface: interface_request.interface.clone(),
|
||||||
|
scope_ref: None,
|
||||||
descriptor: descriptor(),
|
descriptor: descriptor(),
|
||||||
validator: Some(vec![4, 5, 6, 255]),
|
validator: Some(vec![4, 5, 6, 255]),
|
||||||
};
|
};
|
||||||
@@ -383,7 +392,7 @@ fn malformed_unknown_null_and_noncanonical_base64_are_rejected() {
|
|||||||
let descriptor = descriptor();
|
let descriptor = descriptor();
|
||||||
let body = serde_json::to_vec(&serde_json::json!({
|
let body = serde_json::to_vec(&serde_json::json!({
|
||||||
"target": {"path": "/items/123", "validator": null},
|
"target": {"path": "/items/123", "validator": null},
|
||||||
"interface": {"reference": "interface-v1"},
|
"interface": {"reference": {"scope": "/", "name": "interface-v1"}},
|
||||||
"operation": "everything",
|
"operation": "everything",
|
||||||
"arguments": {}
|
"arguments": {}
|
||||||
}))
|
}))
|
||||||
@@ -443,10 +452,11 @@ fn caller_limits_remain_local_codec_failures() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn unsupported_descriptor_format_is_client_local() {
|
fn unsupported_descriptor_format_is_client_local() {
|
||||||
let request = FetchInterfaceRequest {
|
let request = FetchInterfaceRequest {
|
||||||
interface: "interface-v1".into(),
|
interface: reference("/", "interface-v1"),
|
||||||
};
|
};
|
||||||
let mut interface = FetchInterfaceResponse {
|
let mut interface = FetchInterfaceResponse {
|
||||||
interface: request.interface.clone(),
|
interface: request.interface.clone(),
|
||||||
|
scope_ref: None,
|
||||||
descriptor: descriptor(),
|
descriptor: descriptor(),
|
||||||
validator: None,
|
validator: None,
|
||||||
};
|
};
|
||||||
@@ -462,7 +472,7 @@ fn unsupported_descriptor_format_is_client_local() {
|
|||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header("content-type", "application/json; charset=utf-8")
|
.header("content-type", "application/json; charset=utf-8")
|
||||||
.body(
|
.body(
|
||||||
br#"{"interface":"interface-v1","descriptor":{"format":"wip-interface/2","future_shape":true}}"#
|
br#"{"interface":{"scope":"/","name":"interface-v1"},"descriptor":{"format":"wip-interface/2","future_shape":true}}"#
|
||||||
.to_vec(),
|
.to_vec(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -473,8 +483,8 @@ fn unsupported_descriptor_format_is_client_local() {
|
|||||||
));
|
));
|
||||||
|
|
||||||
for malformed in [
|
for malformed in [
|
||||||
br#"{"interface":"wrong","descriptor":{"format":"wip-interface/2"}}"#.as_slice(),
|
br#"{"interface":{"scope":"/","name":"wrong"},"descriptor":{"format":"wip-interface/2"}}"#.as_slice(),
|
||||||
br#"{"interface":"interface-v1","descriptor":{"format":"wip-interface/2"},"validator":null}"#
|
br#"{"interface":{"scope":"/","name":"interface-v1"},"descriptor":{"format":"wip-interface/2"},"validator":null}"#
|
||||||
.as_slice(),
|
.as_slice(),
|
||||||
] {
|
] {
|
||||||
let response = wip_http::http::Response::builder()
|
let response = wip_http::http::Response::builder()
|
||||||
|
|||||||
@@ -0,0 +1,740 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use serde_json::{Value as Json, json};
|
||||||
|
use wip_http::http::{Response, StatusCode};
|
||||||
|
use wip_http::wip_protocol::{
|
||||||
|
CallOperationRequest, FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1,
|
||||||
|
InterfaceDescriptor, InterfaceReference, InterfaceTarget, Object, ObjectObservation,
|
||||||
|
ObserveRequest, OperationDeclaration, ProtocolError, ProtocolErrorCode, ProtocolInteraction,
|
||||||
|
ReturnDeclaration, Target, TypeExpr,
|
||||||
|
};
|
||||||
|
use wip_http::{
|
||||||
|
ClientResponseError, CodecError, DecodedResponse, Endpoint, InvalidResponseKind, Limits,
|
||||||
|
TransportFailureKind, decode_call_operation_metadata, decode_call_operation_request,
|
||||||
|
decode_call_operation_response, decode_fetch_interface_request,
|
||||||
|
decode_fetch_interface_response, decode_observe_response, encode_call_operation_request,
|
||||||
|
encode_fetch_interface_request, encode_fetch_interface_response, encode_observe_response,
|
||||||
|
encode_protocol_error_response,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn limits() -> Limits {
|
||||||
|
Limits::new(64 * 1024, 64 * 1024, 64).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn endpoint() -> Endpoint {
|
||||||
|
Endpoint::parse("https://example.test/deployment/prefix").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reference(scope: &str, name: &str) -> InterfaceReference {
|
||||||
|
InterfaceReference {
|
||||||
|
scope: scope.into(),
|
||||||
|
name: name.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::Unit,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call(reference: InterfaceReference, scope_ref: Option<String>) -> CallOperationRequest {
|
||||||
|
CallOperationRequest {
|
||||||
|
target: Target {
|
||||||
|
path: "/世界::#/item".into(),
|
||||||
|
validator: Some(vec![0, 255]),
|
||||||
|
},
|
||||||
|
interface: InterfaceTarget {
|
||||||
|
reference,
|
||||||
|
scope_ref,
|
||||||
|
validator: Some(vec![1, 255]),
|
||||||
|
},
|
||||||
|
operation: "run#::操作".into(),
|
||||||
|
arguments: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn observation(references: Vec<InterfaceReference>) -> ObjectObservation {
|
||||||
|
ObjectObservation {
|
||||||
|
object: Object {
|
||||||
|
name: "item".into(),
|
||||||
|
description: None,
|
||||||
|
interfaces: references,
|
||||||
|
r#ref: None,
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
children: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn observe_request() -> ObserveRequest {
|
||||||
|
ObserveRequest {
|
||||||
|
path: "/世界::#/item".into(),
|
||||||
|
depth: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn response(status: StatusCode, value: Json) -> Response<Vec<u8>> {
|
||||||
|
raw_response(status, &serde_json::to_vec(&value).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn raw_response(status: StatusCode, body: &[u8]) -> Response<Vec<u8>> {
|
||||||
|
Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(body.to_vec())
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_json(interface: Json) -> Json {
|
||||||
|
json!({
|
||||||
|
"interface": interface,
|
||||||
|
"descriptor": {"format": INTERFACE_FORMAT_V1, "types": [], "operations": []}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call_json(reference: Json) -> Json {
|
||||||
|
json!({
|
||||||
|
"target": {"path": "/世界::#/item"},
|
||||||
|
"interface": {"reference": reference},
|
||||||
|
"operation": "run#::操作",
|
||||||
|
"arguments": {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_success<T: std::fmt::Debug>(result: Result<T, ClientResponseError>) {
|
||||||
|
assert!(matches!(
|
||||||
|
result.unwrap_err(),
|
||||||
|
ClientResponseError::InvalidResponse {
|
||||||
|
kind: InvalidResponseKind::InvalidSuccessBody,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exact_structured_reference_is_encoded_and_round_tripped_in_every_position() {
|
||||||
|
let interface_ref = reference("/世界::#", "é::接口#read/with\"quote\\slash");
|
||||||
|
let expected = json!({"scope": interface_ref.scope, "name": interface_ref.name});
|
||||||
|
let fetch_request = FetchInterfaceRequest {
|
||||||
|
interface: interface_ref.clone(),
|
||||||
|
};
|
||||||
|
let encoded = encode_fetch_interface_request(&endpoint(), &fetch_request, limits()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
encoded.uri().path(),
|
||||||
|
"/deployment/prefix/v1/fetch_interface"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_slice::<Json>(encoded.body()).unwrap(),
|
||||||
|
json!({"interface": expected})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decode_fetch_interface_request(encoded.body(), limits()).unwrap(),
|
||||||
|
fetch_request
|
||||||
|
);
|
||||||
|
|
||||||
|
let fetch_response = FetchInterfaceResponse {
|
||||||
|
interface: interface_ref.clone(),
|
||||||
|
scope_ref: Some("opaque:世界::# not base64!".into()),
|
||||||
|
descriptor: descriptor(),
|
||||||
|
validator: Some(vec![1, 255]),
|
||||||
|
};
|
||||||
|
let encoded =
|
||||||
|
encode_fetch_interface_response(&fetch_request, &fetch_response, limits()).unwrap();
|
||||||
|
let body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||||
|
assert_eq!(body["interface"], expected);
|
||||||
|
assert_eq!(body["scope_ref"], "opaque:世界::# not base64!");
|
||||||
|
assert_eq!(body["validator"], "Af8=");
|
||||||
|
assert_eq!(
|
||||||
|
decode_fetch_interface_response(&fetch_request, &encoded, limits()).unwrap(),
|
||||||
|
DecodedResponse::Success(fetch_response)
|
||||||
|
);
|
||||||
|
|
||||||
|
let observation = observation(vec![interface_ref.clone(), reference("/", "root")]);
|
||||||
|
let encoded = encode_observe_response(&observe_request(), &observation, limits()).unwrap();
|
||||||
|
let body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
body["object"]["interfaces"],
|
||||||
|
json!([expected, {"scope": "/", "name": "root"}])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decode_observe_response(&observe_request(), &encoded, limits()).unwrap(),
|
||||||
|
DecodedResponse::Success(observation)
|
||||||
|
);
|
||||||
|
|
||||||
|
let request = call(interface_ref, Some("opaque:世界::# not base64!".into()));
|
||||||
|
let encoded =
|
||||||
|
encode_call_operation_request(&endpoint(), &request, &descriptor(), limits()).unwrap();
|
||||||
|
let body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||||
|
assert_eq!(body["interface"]["reference"], expected);
|
||||||
|
assert_eq!(body["interface"]["scope_ref"], "opaque:世界::# not base64!");
|
||||||
|
assert_eq!(body["interface"]["validator"], "Af8=");
|
||||||
|
assert_eq!(body["target"]["validator"], "AP8=");
|
||||||
|
assert_eq!(body["operation"], "run#::操作");
|
||||||
|
assert_eq!(
|
||||||
|
decode_call_operation_request(encoded.body(), &descriptor(), limits()).unwrap(),
|
||||||
|
request
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decode_call_operation_metadata(encoded.body(), limits())
|
||||||
|
.unwrap()
|
||||||
|
.interface,
|
||||||
|
request.interface
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_reference_corpus_is_rejected_in_all_positions() {
|
||||||
|
let request = FetchInterfaceRequest {
|
||||||
|
interface: reference("/", "interface-v1"),
|
||||||
|
};
|
||||||
|
for bad in [
|
||||||
|
json!("interface-v1"),
|
||||||
|
json!("/::interface-v1"),
|
||||||
|
json!("interface-v1#run"),
|
||||||
|
Json::Null,
|
||||||
|
json!(false),
|
||||||
|
json!(1),
|
||||||
|
json!([]),
|
||||||
|
json!({}),
|
||||||
|
json!({"name": "interface-v1"}),
|
||||||
|
json!({"scope": "/"}),
|
||||||
|
json!({"scope": null, "name": "interface-v1"}),
|
||||||
|
json!({"scope": 1, "name": "interface-v1"}),
|
||||||
|
json!({"scope": "/", "name": null}),
|
||||||
|
json!({"scope": "/", "name": []}),
|
||||||
|
json!({"scope": "/", "name": ""}),
|
||||||
|
json!({"scope": "/", "name": "interface-v1", "extra": true}),
|
||||||
|
json!({"scope": "/", "name": "interface-v1", "scope_ref": "identity"}),
|
||||||
|
json!({"Scope": "/", "name": "interface-v1"}),
|
||||||
|
json!({"scope": "", "name": "interface-v1"}),
|
||||||
|
json!({"scope": "relative", "name": "interface-v1"}),
|
||||||
|
json!({"scope": ".", "name": "interface-v1"}),
|
||||||
|
json!({"scope": "..", "name": "interface-v1"}),
|
||||||
|
json!({"scope": "/a/", "name": "interface-v1"}),
|
||||||
|
json!({"scope": "//a", "name": "interface-v1"}),
|
||||||
|
json!({"scope": "/a//b", "name": "interface-v1"}),
|
||||||
|
json!({"scope": "/a/./b", "name": "interface-v1"}),
|
||||||
|
json!({"scope": "/a/../b", "name": "interface-v1"}),
|
||||||
|
] {
|
||||||
|
let body = serde_json::to_vec(&json!({"interface": bad})).unwrap();
|
||||||
|
assert!(
|
||||||
|
decode_fetch_interface_request(&body, limits()).is_err(),
|
||||||
|
"{bad}"
|
||||||
|
);
|
||||||
|
let body = serde_json::to_vec(&call_json(bad.clone())).unwrap();
|
||||||
|
assert!(
|
||||||
|
decode_call_operation_metadata(&body, limits()).is_err(),
|
||||||
|
"{bad}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
decode_call_operation_request(&body, &descriptor(), limits()).is_err(),
|
||||||
|
"{bad}"
|
||||||
|
);
|
||||||
|
invalid_success(decode_fetch_interface_response(
|
||||||
|
&request,
|
||||||
|
&response(StatusCode::OK, fetch_json(bad.clone())),
|
||||||
|
limits(),
|
||||||
|
));
|
||||||
|
let mut future = fetch_json(bad.clone());
|
||||||
|
future["descriptor"] = json!({"format": "future-format", "future": true});
|
||||||
|
invalid_success(decode_fetch_interface_response(
|
||||||
|
&request,
|
||||||
|
&response(StatusCode::OK, future),
|
||||||
|
limits(),
|
||||||
|
));
|
||||||
|
invalid_success(decode_observe_response(
|
||||||
|
&observe_request(),
|
||||||
|
&response(
|
||||||
|
StatusCode::OK,
|
||||||
|
json!({"object": {"name": "item", "interfaces": [bad]}}),
|
||||||
|
),
|
||||||
|
limits(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_reference_members_are_rejected_even_when_escaped_or_equal() {
|
||||||
|
let request = FetchInterfaceRequest {
|
||||||
|
interface: reference("/", "x"),
|
||||||
|
};
|
||||||
|
for bad in [
|
||||||
|
r#"{"scope":"/","scope":"/","name":"x"}"#,
|
||||||
|
r#"{"scope":"/","name":"x","name":"x"}"#,
|
||||||
|
r#"{"scope":"/","name":"x","na\u006de":"x"}"#,
|
||||||
|
] {
|
||||||
|
let fetch = format!(r#"{{"interface":{bad}}}"#);
|
||||||
|
assert!(matches!(
|
||||||
|
decode_fetch_interface_request(fetch.as_bytes(), limits()).unwrap_err(),
|
||||||
|
CodecError::InvalidJson(_)
|
||||||
|
));
|
||||||
|
let call = format!(
|
||||||
|
r#"{{"target":{{"path":"/"}},"interface":{{"reference":{bad}}},"operation":"run","arguments":{{}}}}"#
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
decode_call_operation_metadata(call.as_bytes(), limits()).unwrap_err(),
|
||||||
|
CodecError::InvalidJson(_)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
decode_call_operation_request(call.as_bytes(), &descriptor(), limits()).unwrap_err(),
|
||||||
|
CodecError::InvalidJson(_)
|
||||||
|
));
|
||||||
|
let fetch_response = format!(
|
||||||
|
r#"{{"interface":{bad},"descriptor":{{"format":"wip-interface/1","types":[],"operations":[]}}}}"#
|
||||||
|
);
|
||||||
|
invalid_success(decode_fetch_interface_response(
|
||||||
|
&request,
|
||||||
|
&raw_response(StatusCode::OK, fetch_response.as_bytes()),
|
||||||
|
limits(),
|
||||||
|
));
|
||||||
|
let observe = format!(r#"{{"object":{{"name":"item","interfaces":[{bad}]}}}}"#);
|
||||||
|
invalid_success(decode_observe_response(
|
||||||
|
&observe_request(),
|
||||||
|
&raw_response(StatusCode::OK, observe.as_bytes()),
|
||||||
|
limits(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn member_order_and_json_escapes_do_not_change_reference_identity() {
|
||||||
|
let expected = reference("/世界::#", "é::接口#");
|
||||||
|
let raw_reference = r#"{"name":"\u00e9::\u63a5\u53e3#","scope":"\/\u4e16\u754c::#"}"#;
|
||||||
|
let fetch = format!(r#"{{"interface":{raw_reference}}}"#);
|
||||||
|
let request = decode_fetch_interface_request(fetch.as_bytes(), limits()).unwrap();
|
||||||
|
assert_eq!(request.interface, expected);
|
||||||
|
let fetch_response = format!(
|
||||||
|
r#"{{"descriptor":{{"operations":[],"types":[],"format":"wip-interface/1"}},"interface":{raw_reference}}}"#
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(decode_fetch_interface_response(&request, &raw_response(StatusCode::OK, fetch_response.as_bytes()), limits()).unwrap(), DecodedResponse::Success(value) if value.interface == expected)
|
||||||
|
);
|
||||||
|
let call = format!(
|
||||||
|
r#"{{"arguments":{{}},"operation":"run#::操作","interface":{{"reference":{raw_reference}}},"target":{{"path":"/世界::#/item"}}}}"#
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decode_call_operation_metadata(call.as_bytes(), limits())
|
||||||
|
.unwrap()
|
||||||
|
.interface
|
||||||
|
.reference,
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decode_call_operation_request(call.as_bytes(), &descriptor(), limits())
|
||||||
|
.unwrap()
|
||||||
|
.interface
|
||||||
|
.reference,
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
let observe = format!(r#"{{"object":{{"interfaces":[{raw_reference}],"name":"item"}}}}"#);
|
||||||
|
assert!(
|
||||||
|
matches!(decode_observe_response(&observe_request(), &raw_response(StatusCode::OK, observe.as_bytes()), limits()).unwrap(), DecodedResponse::Success(value) if value.object.interfaces == vec![expected])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reference_comparison_is_exact_without_normalization_case_or_url_decoding() {
|
||||||
|
for different in [
|
||||||
|
reference("/", "e\u{301}"),
|
||||||
|
reference("/", "É"),
|
||||||
|
reference("/", "%C3%A9"),
|
||||||
|
reference("/other", "é"),
|
||||||
|
] {
|
||||||
|
let request = FetchInterfaceRequest {
|
||||||
|
interface: reference("/", "é"),
|
||||||
|
};
|
||||||
|
invalid_success(decode_fetch_interface_response(
|
||||||
|
&request,
|
||||||
|
&response(
|
||||||
|
StatusCode::OK,
|
||||||
|
fetch_json(json!({"scope": different.scope, "name": different.name})),
|
||||||
|
),
|
||||||
|
limits(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let request = FetchInterfaceRequest {
|
||||||
|
interface: reference("/%2E", "é"),
|
||||||
|
};
|
||||||
|
let encoded = encode_fetch_interface_request(&endpoint(), &request, limits()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
decode_fetch_interface_request(encoded.body(), limits()).unwrap(),
|
||||||
|
request
|
||||||
|
);
|
||||||
|
let request = FetchInterfaceRequest {
|
||||||
|
interface: reference("/", "."),
|
||||||
|
};
|
||||||
|
assert!(encode_fetch_interface_request(&endpoint(), &request, limits()).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_encoding_rejects_invalid_reference_shape_without_needing_a_fetch_target() {
|
||||||
|
for bad in [
|
||||||
|
reference("relative", "x"),
|
||||||
|
reference("", "x"),
|
||||||
|
reference("/a/../b", "x"),
|
||||||
|
reference("/", ""),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
encode_fetch_interface_request(
|
||||||
|
&endpoint(),
|
||||||
|
&FetchInterfaceRequest {
|
||||||
|
interface: bad.clone()
|
||||||
|
},
|
||||||
|
limits()
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
encode_call_operation_request(&endpoint(), &call(bad, None), &descriptor(), limits())
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let request = FetchInterfaceRequest {
|
||||||
|
interface: reference("/unrelated/scope", "x"),
|
||||||
|
};
|
||||||
|
let encoded = encode_fetch_interface_request(&endpoint(), &request, limits()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
decode_fetch_interface_request(encoded.body(), limits()).unwrap(),
|
||||||
|
request
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn call_metadata_leaves_valid_out_of_scope_references_and_identity_to_host_resolution() {
|
||||||
|
let request = call(
|
||||||
|
reference("/elsewhere", "x"),
|
||||||
|
Some("not-current-identity".into()),
|
||||||
|
);
|
||||||
|
let encoded =
|
||||||
|
encode_call_operation_request(&endpoint(), &request, &descriptor(), limits()).unwrap();
|
||||||
|
let metadata = decode_call_operation_metadata(encoded.body(), limits()).unwrap();
|
||||||
|
assert_eq!(metadata.interface, request.interface);
|
||||||
|
assert_eq!(
|
||||||
|
metadata
|
||||||
|
.decode_request(encoded.body(), &descriptor(), limits())
|
||||||
|
.unwrap(),
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
|
// Neither an unknown operation nor descriptor-bound argument values are
|
||||||
|
// resolved by the metadata boundary before Host precondition checks.
|
||||||
|
let mut body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||||
|
body["operation"] = json!("missing");
|
||||||
|
body["arguments"] = json!({"not_declared": [null, {"anything": true}]});
|
||||||
|
let metadata =
|
||||||
|
decode_call_operation_metadata(&serde_json::to_vec(&body).unwrap(), limits()).unwrap();
|
||||||
|
assert_eq!(metadata.operation, "missing");
|
||||||
|
assert_eq!(metadata.interface, request.interface);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn observation_scope_validation_uses_each_nodes_path_and_structural_duplicates() {
|
||||||
|
let request = ObserveRequest {
|
||||||
|
path: "/a".into(),
|
||||||
|
depth: 1,
|
||||||
|
};
|
||||||
|
let valid = json!({
|
||||||
|
"object": {"name": "a", "interfaces": [{"scope": "/", "name": "x"}, {"scope": "/a", "name": "x"}]},
|
||||||
|
"children": [{"object": {"name": "b", "interfaces": [{"scope": "/a/b", "name": "x"}]}}]
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
decode_observe_response(&request, &response(StatusCode::OK, valid.clone()), limits())
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
for scope in ["/ab", "/elsewhere", "/a/b/c"] {
|
||||||
|
let mut bad = valid.clone();
|
||||||
|
bad["children"][0]["object"]["interfaces"][0]["scope"] = json!(scope);
|
||||||
|
invalid_success(decode_observe_response(
|
||||||
|
&request,
|
||||||
|
&response(StatusCode::OK, bad),
|
||||||
|
limits(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut duplicate = valid;
|
||||||
|
duplicate["object"]["interfaces"] =
|
||||||
|
json!([{"scope": "/a", "name": "x"}, {"name": "x", "scope": "/a"}]);
|
||||||
|
invalid_success(decode_observe_response(
|
||||||
|
&request,
|
||||||
|
&response(StatusCode::OK, duplicate),
|
||||||
|
limits(),
|
||||||
|
));
|
||||||
|
let root_request = ObserveRequest {
|
||||||
|
path: "/".into(),
|
||||||
|
depth: 0,
|
||||||
|
};
|
||||||
|
for (scope, valid) in [("/", true), ("/a", false)] {
|
||||||
|
let result = decode_observe_response(
|
||||||
|
&root_request,
|
||||||
|
&response(
|
||||||
|
StatusCode::OK,
|
||||||
|
json!({"object": {"name": "", "interfaces": [{"scope": scope, "name": "x"}]}}),
|
||||||
|
),
|
||||||
|
limits(),
|
||||||
|
);
|
||||||
|
if valid {
|
||||||
|
assert!(result.is_ok());
|
||||||
|
} else {
|
||||||
|
invalid_success(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_ref_omission_and_opaque_strings_round_trip_in_both_positions() {
|
||||||
|
for scope_ref in [
|
||||||
|
None,
|
||||||
|
Some(String::new()),
|
||||||
|
Some("opaque::# 世界 ! not base64 /+=".into()),
|
||||||
|
Some("\"\\\n\u{0}".into()),
|
||||||
|
] {
|
||||||
|
let request = FetchInterfaceRequest {
|
||||||
|
interface: reference("/", "x"),
|
||||||
|
};
|
||||||
|
let expected = FetchInterfaceResponse {
|
||||||
|
interface: request.interface.clone(),
|
||||||
|
scope_ref: scope_ref.clone(),
|
||||||
|
descriptor: descriptor(),
|
||||||
|
validator: None,
|
||||||
|
};
|
||||||
|
let encoded = encode_fetch_interface_response(&request, &expected, limits()).unwrap();
|
||||||
|
let body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
body.get("scope_ref"),
|
||||||
|
scope_ref
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| Json::String(value.clone()))
|
||||||
|
.as_ref()
|
||||||
|
);
|
||||||
|
assert!(body.get("validator").is_none());
|
||||||
|
assert_eq!(
|
||||||
|
decode_fetch_interface_response(&request, &encoded, limits()).unwrap(),
|
||||||
|
DecodedResponse::Success(expected)
|
||||||
|
);
|
||||||
|
let call = call(request.interface, scope_ref);
|
||||||
|
let encoded =
|
||||||
|
encode_call_operation_request(&endpoint(), &call, &descriptor(), limits()).unwrap();
|
||||||
|
let body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
body["interface"].get("scope_ref"),
|
||||||
|
call.interface
|
||||||
|
.scope_ref
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| Json::String(value.clone()))
|
||||||
|
.as_ref()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decode_call_operation_metadata(encoded.body(), limits())
|
||||||
|
.unwrap()
|
||||||
|
.interface,
|
||||||
|
call.interface
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decode_call_operation_request(encoded.body(), &descriptor(), limits()).unwrap(),
|
||||||
|
call
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_ref_null_and_every_nonstring_type_are_rejected_before_descriptor_resolution() {
|
||||||
|
let request = FetchInterfaceRequest {
|
||||||
|
interface: reference("/", "x"),
|
||||||
|
};
|
||||||
|
for bad in [Json::Null, json!(true), json!(0), json!([]), json!({})] {
|
||||||
|
let mut fetch = fetch_json(json!({"scope": "/", "name": "x"}));
|
||||||
|
fetch["scope_ref"] = bad.clone();
|
||||||
|
invalid_success(decode_fetch_interface_response(
|
||||||
|
&request,
|
||||||
|
&response(StatusCode::OK, fetch.clone()),
|
||||||
|
limits(),
|
||||||
|
));
|
||||||
|
// Unknown format does not mask malformed common response fields.
|
||||||
|
fetch["descriptor"] = json!({"format": "future-format", "future": true});
|
||||||
|
invalid_success(decode_fetch_interface_response(
|
||||||
|
&request,
|
||||||
|
&response(StatusCode::OK, fetch),
|
||||||
|
limits(),
|
||||||
|
));
|
||||||
|
let mut call = call_json(json!({"scope": "/", "name": "x"}));
|
||||||
|
call["interface"]["scope_ref"] = bad.clone();
|
||||||
|
let body = serde_json::to_vec(&call).unwrap();
|
||||||
|
let error = decode_call_operation_metadata(&body, limits()).unwrap_err();
|
||||||
|
if bad.is_null() {
|
||||||
|
assert!(matches!(error, CodecError::NullOptionalField { .. }));
|
||||||
|
} else {
|
||||||
|
assert!(matches!(error, CodecError::InvalidField { .. }));
|
||||||
|
}
|
||||||
|
assert!(decode_call_operation_request(&body, &descriptor(), limits()).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_ref_is_only_allowed_on_fetch_responses_and_call_interface_targets() {
|
||||||
|
let request = FetchInterfaceRequest {
|
||||||
|
interface: reference("/", "x"),
|
||||||
|
};
|
||||||
|
let reference = json!({"scope": "/", "name": "x"});
|
||||||
|
let fetch_request = json!({"interface": reference, "scope_ref": "identity"});
|
||||||
|
assert!(
|
||||||
|
decode_fetch_interface_request(&serde_json::to_vec(&fetch_request).unwrap(), limits())
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut call = call_json(reference.clone());
|
||||||
|
call["scope_ref"] = json!("identity");
|
||||||
|
assert!(decode_call_operation_metadata(&serde_json::to_vec(&call).unwrap(), limits()).is_err());
|
||||||
|
let mut call = call_json(reference);
|
||||||
|
call["target"]["scope_ref"] = json!("identity");
|
||||||
|
assert!(decode_call_operation_metadata(&serde_json::to_vec(&call).unwrap(), limits()).is_err());
|
||||||
|
|
||||||
|
let duplicate = br#"{"interface":{"scope":"/","name":"x"},"scope_ref":"identity","scope_ref":"identity","descriptor":{"format":"wip-interface/1","types":[],"operations":[]}}"#;
|
||||||
|
invalid_success(decode_fetch_interface_response(
|
||||||
|
&request,
|
||||||
|
&raw_response(StatusCode::OK, duplicate),
|
||||||
|
limits(),
|
||||||
|
));
|
||||||
|
let duplicate = br#"{"target":{"path":"/"},"interface":{"reference":{"scope":"/","name":"x"},"scope_ref":"identity","scope_ref":"identity"},"operation":"run#::\u64cd\u4f5c","arguments":{}}"#;
|
||||||
|
assert!(matches!(
|
||||||
|
decode_call_operation_metadata(duplicate, limits()).unwrap_err(),
|
||||||
|
CodecError::InvalidJson(_)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
decode_call_operation_request(duplicate, &descriptor(), limits()).unwrap_err(),
|
||||||
|
CodecError::InvalidJson(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metadata_correspondence_includes_reference_and_scope_ref_but_not_wire_order() {
|
||||||
|
let request = call(reference("/", "x"), Some("identity".into()));
|
||||||
|
let encoded =
|
||||||
|
encode_call_operation_request(&endpoint(), &request, &descriptor(), limits()).unwrap();
|
||||||
|
let metadata = decode_call_operation_metadata(encoded.body(), limits()).unwrap();
|
||||||
|
for (field, changed) in [
|
||||||
|
("scope_ref", json!("other")),
|
||||||
|
("reference", json!({"scope": "/", "name": "other"})),
|
||||||
|
] {
|
||||||
|
let mut body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||||
|
body["interface"][field] = changed;
|
||||||
|
assert!(
|
||||||
|
matches!(metadata.decode_request(&serde_json::to_vec(&body).unwrap(), &descriptor(), limits()).unwrap_err(), CodecError::InvalidField { field, .. } if field == "request")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||||
|
body["interface"]
|
||||||
|
.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.remove("scope_ref");
|
||||||
|
assert!(
|
||||||
|
metadata
|
||||||
|
.decode_request(&serde_json::to_vec(&body).unwrap(), &descriptor(), limits())
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fetch_classification_preserves_transport_status_and_error_envelope_semantics() {
|
||||||
|
let request = FetchInterfaceRequest {
|
||||||
|
interface: reference("/", "x"),
|
||||||
|
};
|
||||||
|
let valid = fetch_json(json!({"scope": "/", "name": "x"}));
|
||||||
|
for (status, kind) in [
|
||||||
|
(
|
||||||
|
StatusCode::CREATED,
|
||||||
|
InvalidResponseKind::NonCanonicalSuccessStatus,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
InvalidResponseKind::SuccessBodyOnErrorStatus,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
matches!(decode_fetch_interface_response(&request, &response(status, valid.clone()), limits()).unwrap_err(), ClientResponseError::InvalidResponse { kind: actual, .. } if actual == kind)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for status in [
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
] {
|
||||||
|
let malformed = response(status, fetch_json(json!("legacy")));
|
||||||
|
assert!(
|
||||||
|
matches!(decode_fetch_interface_response(&request, &malformed, limits()).unwrap_err(), ClientResponseError::TransportBinding(failure) if failure.status == status)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let error = ProtocolError {
|
||||||
|
code: ProtocolErrorCode::InterfaceNotFound,
|
||||||
|
message: "unpublished".into(),
|
||||||
|
};
|
||||||
|
let mut encoded =
|
||||||
|
encode_protocol_error_response(ProtocolInteraction::FetchInterface, &error, limits())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
decode_fetch_interface_response(&request, &encoded, limits()).unwrap(),
|
||||||
|
DecodedResponse::ProtocolFailure {
|
||||||
|
error: error.clone(),
|
||||||
|
status_mismatch: None
|
||||||
|
}
|
||||||
|
);
|
||||||
|
*encoded.status_mut() = StatusCode::BAD_GATEWAY;
|
||||||
|
assert!(
|
||||||
|
matches!(decode_fetch_interface_response(&request, &encoded, limits()).unwrap(), DecodedResponse::ProtocolFailure { error: actual, status_mismatch: Some(_) } if actual == error)
|
||||||
|
);
|
||||||
|
*encoded.status_mut() = StatusCode::OK;
|
||||||
|
assert!(matches!(
|
||||||
|
decode_fetch_interface_response(&request, &encoded, limits()).unwrap_err(),
|
||||||
|
ClientResponseError::InvalidResponse {
|
||||||
|
kind: InvalidResponseKind::ErrorEnvelopeOnSuccessStatus,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
let disallowed = response(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
json!({"error": {"code": "interface_mismatch", "message": "wrong interaction"}}),
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
decode_fetch_interface_response(&request, &disallowed, limits()).unwrap_err(),
|
||||||
|
ClientResponseError::InvalidResponse {
|
||||||
|
kind: InvalidResponseKind::DisallowedProtocolCode,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
let mut coded = response(StatusCode::OK, valid);
|
||||||
|
coded
|
||||||
|
.headers_mut()
|
||||||
|
.insert("content-encoding", "gzip".parse().unwrap());
|
||||||
|
assert!(
|
||||||
|
matches!(decode_fetch_interface_response(&request, &coded, limits()).unwrap_err(), ClientResponseError::TransportBinding(failure) if failure.kind == TransportFailureKind::UnsupportedContentCoding)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn call_interface_mismatch_envelope_remains_protocol_failure_with_structured_metadata() {
|
||||||
|
let request = call(reference("/unrelated", "x"), Some("stale identity".into()));
|
||||||
|
let error = ProtocolError {
|
||||||
|
code: ProtocolErrorCode::InterfaceMismatch,
|
||||||
|
message: "interface precondition".into(),
|
||||||
|
};
|
||||||
|
let encoded =
|
||||||
|
encode_protocol_error_response(ProtocolInteraction::CallOperation, &error, limits())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
decode_call_operation_response(&request, &descriptor(), &encoded, limits()).unwrap(),
|
||||||
|
DecodedResponse::ProtocolFailure {
|
||||||
|
error,
|
||||||
|
status_mismatch: None
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wip-interface-macros"
|
name = "wip-interface-macros"
|
||||||
version = "0.1.0"
|
version.workspace = true
|
||||||
edition = "2024"
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
description = "Procedural macros for WIP interface declarations and DTO codecs"
|
description = "Procedural macros for WIP interface declarations and DTO codecs"
|
||||||
license = "MIT OR Apache-2.0"
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
readme = "README.md"
|
||||||
|
documentation = "https://docs.rs/wip-interface-macros"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
proc-macro = true
|
proc-macro = true
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Hare
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# wip-interface-macros
|
||||||
|
|
||||||
|
Procedural-macro implementation used by [`wip-interface`](https://crates.io/crates/wip-interface).
|
||||||
|
|
||||||
|
Applications should depend on `wip-interface`, which re-exports `#[interface]` and `#[derive(WipType)]` together with the traits and types required by generated code. Direct use of this implementation crate is not a supported application API.
|
||||||
|
|
||||||
|
See the [API documentation](https://docs.rs/wip-interface-macros) for the exported macro entry points.
|
||||||
|
|
||||||
|
Licensed under either of Apache-2.0 or MIT at your option.
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wip-interface"
|
name = "wip-interface"
|
||||||
version = "0.1.0"
|
version.workspace = true
|
||||||
edition = "2024"
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
description = "Public Rust authoring API for WIP interfaces and operation value codecs"
|
description = "Public Rust authoring API for WIP interfaces and operation value codecs"
|
||||||
license = "MIT OR Apache-2.0"
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
readme = "README.md"
|
||||||
|
documentation = "https://docs.rs/wip-interface"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
wip-interface-macros = { path = "../wip-interface-macros" }
|
wip-interface-macros.workspace = true
|
||||||
wip-protocol = { path = "../wip-protocol" }
|
wip-protocol.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
trybuild = "1"
|
trybuild = "1"
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Hare
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# wip-interface
|
||||||
|
|
||||||
|
Public Rust authoring API for Web 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.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
wip-interface = "0.1.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wip_interface as wip;
|
||||||
|
|
||||||
|
#[derive(wip::WipType)]
|
||||||
|
struct Greeting {
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wip::interface]
|
||||||
|
pub trait Greeter {
|
||||||
|
/// Builds a greeting.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// The generated greeting.
|
||||||
|
fn greet(&self, name: String) -> wip::OperationResult<Greeting>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [API documentation](https://docs.rs/wip-interface) for supported interface syntax, DTO shapes, and failure semantics.
|
||||||
|
|
||||||
|
Licensed under either of Apache-2.0 or MIT at your option.
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wip-protocol"
|
name = "wip-protocol"
|
||||||
version = "0.1.0"
|
version.workspace = true
|
||||||
edition = "2024"
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
description = "Transport-independent logical model and validation for WIP"
|
description = "Transport-independent logical model and validation for WIP"
|
||||||
license = "MIT OR Apache-2.0"
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
readme = "README.md"
|
||||||
|
documentation = "https://docs.rs/wip-protocol"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Hare
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# wip-protocol
|
||||||
|
|
||||||
|
Transport-independent logical types and validation for the Web 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.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
wip-protocol = "0.1.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
Interface identity is an exact `InterfaceReference { scope, name }` pair.
|
||||||
|
`scope` is a canonical absolute Worldspace path and `name` is nonempty Unicode.
|
||||||
|
Use `validate()` for shape, `validate_for_path()` for ancestor-or-self placement,
|
||||||
|
and Object/interaction validation at response boundaries. Fetching needs no
|
||||||
|
target Object path. Fetch responses and interface call targets carry optional
|
||||||
|
opaque string `scope_ref` metadata, not a third identity field. Descriptors do
|
||||||
|
not embed identity. See the repository's [migration guide](../../MIGRATION.md)
|
||||||
|
for the breaking Rust and HTTP wire changes and deferred consumer semantics.
|
||||||
|
|
||||||
|
See the [API documentation](https://docs.rs/wip-protocol) for the complete model and validation rules.
|
||||||
|
|
||||||
|
Licensed under either of Apache-2.0 or MIT at your option.
|
||||||
@@ -143,8 +143,17 @@ pub enum ValidationErrorKind {
|
|||||||
},
|
},
|
||||||
/// An object's ordered interface set contains a duplicate.
|
/// An object's ordered interface set contains a duplicate.
|
||||||
DuplicateInterface {
|
DuplicateInterface {
|
||||||
/// Duplicate opaque interface reference.
|
/// Duplicate structured interface reference.
|
||||||
reference: String,
|
reference: crate::InterfaceReference,
|
||||||
|
},
|
||||||
|
/// The local interface name is empty.
|
||||||
|
EmptyInterfaceName,
|
||||||
|
/// A valid scope is not an ancestor-or-self of the target path.
|
||||||
|
InterfaceScopeMismatch {
|
||||||
|
/// Selected scope path.
|
||||||
|
scope: String,
|
||||||
|
/// Target Object path.
|
||||||
|
path: String,
|
||||||
},
|
},
|
||||||
/// A string is not a canonical absolute Worldspace path.
|
/// A string is not a canonical absolute Worldspace path.
|
||||||
InvalidPath {
|
InvalidPath {
|
||||||
@@ -175,10 +184,10 @@ pub enum ValidationErrorKind {
|
|||||||
},
|
},
|
||||||
/// A response refers to a different interface than its request.
|
/// A response refers to a different interface than its request.
|
||||||
InterfaceResponseMismatch {
|
InterfaceResponseMismatch {
|
||||||
/// Requested opaque reference.
|
/// Requested structured reference (boxed to keep validation errors small).
|
||||||
expected: String,
|
expected: Box<crate::InterfaceReference>,
|
||||||
/// Returned opaque reference.
|
/// Returned structured reference (boxed to keep validation errors small).
|
||||||
actual: String,
|
actual: Box<crate::InterfaceReference>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +235,7 @@ impl Error for ValidationError {}
|
|||||||
pub enum ProtocolInteraction {
|
pub enum ProtocolInteraction {
|
||||||
/// Observe one object and its indexable descendants to a requested depth.
|
/// Observe one object and its indexable descendants to a requested depth.
|
||||||
Observe,
|
Observe,
|
||||||
/// Fetch one interface descriptor by its opaque reference.
|
/// Fetch one interface descriptor by its structured reference.
|
||||||
FetchInterface,
|
FetchInterface,
|
||||||
/// Invoke one operation on a path-targeted object.
|
/// Invoke one operation on a path-targeted object.
|
||||||
CallOperation,
|
CallOperation,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
//! guarantee; Clients must not automatically retry it.
|
//! guarantee; Clients must not automatically retry it.
|
||||||
//!
|
//!
|
||||||
//! Paths, object refs, interface refs, and validators remain separate concepts.
|
//! Paths, object refs, interface refs, and validators remain separate concepts.
|
||||||
//! They are represented by ordinary protocol strings/bytes in the corresponding
|
//! Interface refs use [`InterfaceReference`]; other metadata use strings/bytes in their
|
||||||
//! fields. In particular, object refs are optional, operation targeting uses a
|
//! fields. In particular, object refs are optional, operation targeting uses a
|
||||||
//! path, and this crate defines no interaction that fetches an object by ref.
|
//! path, and this crate defines no interaction that fetches an object by ref.
|
||||||
//!
|
//!
|
||||||
@@ -52,9 +52,10 @@ pub use error::{
|
|||||||
};
|
};
|
||||||
pub use model::{
|
pub use model::{
|
||||||
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
|
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
|
||||||
FetchInterfaceResponse, FieldDeclaration, InterfaceDescriptor, InterfaceTarget, Object,
|
FetchInterfaceResponse, FieldDeclaration, InterfaceDescriptor, InterfaceReference,
|
||||||
ObjectObservation, ObserveRequest, ObserveResponse, OperationDeclaration, ParameterDeclaration,
|
InterfaceTarget, Object, ObjectObservation, ObserveRequest, ObserveResponse,
|
||||||
ProtocolResult, ReturnDeclaration, Target, TypeDeclaration, TypeExpr, UnionCase, Value,
|
OperationDeclaration, ParameterDeclaration, ProtocolResult, ReturnDeclaration, Target,
|
||||||
|
TypeDeclaration, TypeExpr, UnionCase, Value,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Validates a canonical absolute Worldspace path.
|
/// Validates a canonical absolute Worldspace path.
|
||||||
|
|||||||
@@ -11,9 +11,49 @@ pub struct Documentation {
|
|||||||
pub details: Option<String>,
|
pub details: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An interface identified by an exact `(scope, name)` pair in one Worldspace.
|
||||||
|
///
|
||||||
|
/// Neither field is normalized, case-folded, URL-decoded, or split on display
|
||||||
|
/// delimiters. Descriptor identity is external to the descriptor itself.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use wip_protocol::{FetchInterfaceRequest, InterfaceReference};
|
||||||
|
/// let reference = InterfaceReference {
|
||||||
|
/// scope: "/世::界/#".into(),
|
||||||
|
/// name: "read::書く#λ".into(),
|
||||||
|
/// };
|
||||||
|
/// reference.validate_for_path("/世::界/#/item")?;
|
||||||
|
/// // Fetching is independent of an Object target path.
|
||||||
|
/// FetchInterfaceRequest { interface: reference }.validate()?;
|
||||||
|
/// # Ok::<(), wip_protocol::ValidationError>(())
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct InterfaceReference {
|
||||||
|
/// Canonical absolute Worldspace path of the interface's scope Object.
|
||||||
|
pub scope: String,
|
||||||
|
/// Nonempty Unicode local name, with no delimiter restrictions.
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InterfaceReference {
|
||||||
|
/// Validates reference shape without requiring a target Object path.
|
||||||
|
pub fn validate(&self) -> Result<(), ValidationError> {
|
||||||
|
crate::validation::validate_interface_reference(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates shape and that scope is an ancestor-or-self of `path`.
|
||||||
|
///
|
||||||
|
/// This is independent of indexability. A Host maps a valid reference's
|
||||||
|
/// scope mismatch to `InterfaceMismatch` at the interface-selection stage,
|
||||||
|
/// not to the earlier shape-validation `InvalidRequest` stage.
|
||||||
|
pub fn validate_for_path(&self, path: &str) -> Result<(), ValidationError> {
|
||||||
|
crate::validation::validate_interface_scope(self, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The protocol projection currently published at one Worldspace path.
|
/// The protocol projection currently published at one Worldspace path.
|
||||||
///
|
///
|
||||||
/// `name` is a path segment, `interfaces` is an ordered set of opaque interface
|
/// `name` is a path segment, `interfaces` is an ordered set of structured interface
|
||||||
/// references, `r#ref` is optional object identity, and `validator` is an
|
/// references, `r#ref` is optional object identity, and `validator` is an
|
||||||
/// independent optional observation precondition. In particular, neither the
|
/// independent optional observation precondition. In particular, neither the
|
||||||
/// path nor `r#ref` is a mandatory object identifier.
|
/// path nor `r#ref` is a mandatory object identifier.
|
||||||
@@ -23,8 +63,8 @@ pub struct Object {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
/// Short public description of the object.
|
/// Short public description of the object.
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
/// Opaque interface references in host publication order.
|
/// Structured interface references in host publication order.
|
||||||
pub interfaces: Vec<String>,
|
pub interfaces: Vec<InterfaceReference>,
|
||||||
/// Optional opaque identity within one client-facing Worldspace.
|
/// Optional opaque identity within one client-facing Worldspace.
|
||||||
pub r#ref: Option<String>,
|
pub r#ref: Option<String>,
|
||||||
/// Optional opaque validator for the object's observed state.
|
/// Optional opaque validator for the object's observed state.
|
||||||
@@ -239,18 +279,20 @@ pub struct ObjectObservation {
|
|||||||
/// Successful `observe` response.
|
/// Successful `observe` response.
|
||||||
pub type ObserveResponse = ObjectObservation;
|
pub type ObserveResponse = ObjectObservation;
|
||||||
|
|
||||||
/// Request for the current descriptor of one opaque interface reference.
|
/// Request for the current descriptor of one structured interface reference.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct FetchInterfaceRequest {
|
pub struct FetchInterfaceRequest {
|
||||||
/// Opaque interface reference obtained from an object.
|
/// Structured interface reference obtained from an object.
|
||||||
pub interface: String,
|
pub interface: InterfaceReference,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Successful `fetch_interface` response.
|
/// Successful `fetch_interface` response.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct FetchInterfaceResponse {
|
pub struct FetchInterfaceResponse {
|
||||||
/// Opaque interface reference corresponding to the request.
|
/// Structured interface reference corresponding to the request.
|
||||||
pub interface: String,
|
pub interface: InterfaceReference,
|
||||||
|
/// Optional opaque ref of the scope Object, not part of interface identity.
|
||||||
|
pub scope_ref: Option<String>,
|
||||||
/// Current validated descriptor.
|
/// Current validated descriptor.
|
||||||
pub descriptor: InterfaceDescriptor,
|
pub descriptor: InterfaceDescriptor,
|
||||||
/// Optional opaque validator for this descriptor observation.
|
/// Optional opaque validator for this descriptor observation.
|
||||||
@@ -269,8 +311,10 @@ pub struct Target {
|
|||||||
/// Interface portion of an operation target.
|
/// Interface portion of an operation target.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct InterfaceTarget {
|
pub struct InterfaceTarget {
|
||||||
/// Opaque interface reference selected from the object's interface set.
|
/// Structured reference selected from the object's interface set.
|
||||||
pub reference: String,
|
pub reference: InterfaceReference,
|
||||||
|
/// Optional scope Object ref copied from the same interface observation.
|
||||||
|
pub scope_ref: Option<String>,
|
||||||
/// Optional validator copied from the interface observation.
|
/// Optional validator copied from the interface observation.
|
||||||
pub validator: Option<Vec<u8>>,
|
pub validator: Option<Vec<u8>>,
|
||||||
}
|
}
|
||||||
@@ -369,6 +413,13 @@ impl ObserveRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl FetchInterfaceRequest {
|
||||||
|
/// Validates reference shape; fetching an interface needs no target path.
|
||||||
|
pub fn validate(&self) -> Result<(), ValidationError> {
|
||||||
|
self.interface.validate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl FetchInterfaceResponse {
|
impl FetchInterfaceResponse {
|
||||||
/// Validates request correspondence and descriptor semantics.
|
/// Validates request correspondence and descriptor semantics.
|
||||||
///
|
///
|
||||||
@@ -382,7 +433,16 @@ impl FetchInterfaceResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CallOperationRequest {
|
impl CallOperationRequest {
|
||||||
/// Validates this call against the descriptor selected by `interface`.
|
/// Validates descriptor-independent request shape only.
|
||||||
|
///
|
||||||
|
/// Scope compatibility is deliberately deferred to interface selection so
|
||||||
|
/// that `NotFound` precedes `InterfaceMismatch` at the Host boundary.
|
||||||
|
pub fn validate(&self) -> Result<(), ValidationError> {
|
||||||
|
crate::validation::validate_call_shape(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates this call's shape and descriptor-bound operation/arguments.
|
||||||
|
/// Scope compatibility and membership require Host publication context.
|
||||||
pub fn validate_with(&self, descriptor: &InterfaceDescriptor) -> Result<(), ValidationError> {
|
pub fn validate_with(&self, descriptor: &InterfaceDescriptor) -> Result<(), ValidationError> {
|
||||||
descriptor.validate_call(self)
|
descriptor.validate_call(self)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,22 @@ use std::collections::{BTreeMap, HashMap, HashSet};
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
CallOperationRequest, FetchInterfaceRequest, FetchInterfaceResponse, InterfaceDescriptor,
|
CallOperationRequest, FetchInterfaceRequest, FetchInterfaceResponse, InterfaceDescriptor,
|
||||||
MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, NameNamespace, Object, ObjectObservation, ObserveRequest,
|
InterfaceReference, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, NameNamespace, Object,
|
||||||
PathSegment, TypeDeclaration, TypeExpr, UNION_CASE_FIELD, UNION_VALUE_FIELD, ValidationError,
|
ObjectObservation, ObserveRequest, PathSegment, TypeDeclaration, TypeExpr, UNION_CASE_FIELD,
|
||||||
ValidationErrorKind, Value, ValueKind,
|
UNION_VALUE_FIELD, ValidationError, ValidationErrorKind, Value, ValueKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(crate) fn validate_object(object: &Object) -> Result<(), ValidationError> {
|
pub(crate) fn validate_object(object: &Object) -> Result<(), ValidationError> {
|
||||||
validate_object_name(&object.name, true)?;
|
validate_object_name(&object.name, true)?;
|
||||||
|
|
||||||
let mut seen = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
for interface in &object.interfaces {
|
for (index, interface) in object.interfaces.iter().enumerate() {
|
||||||
if !seen.insert(interface.as_str()) {
|
interface.validate().map_err(|error| {
|
||||||
|
error
|
||||||
|
.prepend(PathSegment::Index(index))
|
||||||
|
.prepend(PathSegment::Field("interfaces".into()))
|
||||||
|
})?;
|
||||||
|
if !seen.insert(interface) {
|
||||||
return Err(
|
return Err(
|
||||||
ValidationError::new(ValidationErrorKind::DuplicateInterface {
|
ValidationError::new(ValidationErrorKind::DuplicateInterface {
|
||||||
reference: interface.clone(),
|
reference: interface.clone(),
|
||||||
@@ -51,6 +56,14 @@ pub(crate) fn validate_object_at_path(object: &Object, path: &str) -> Result<(),
|
|||||||
.next()
|
.next()
|
||||||
.expect("non-root path has a segment")
|
.expect("non-root path has a segment")
|
||||||
};
|
};
|
||||||
|
for (index, interface) in object.interfaces.iter().enumerate() {
|
||||||
|
interface.validate_for_path(path).map_err(|error| {
|
||||||
|
error
|
||||||
|
.prepend(PathSegment::Index(index))
|
||||||
|
.prepend(PathSegment::Field("interfaces".into()))
|
||||||
|
.prepend(PathSegment::Object(path.to_owned()))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
if object.name == expected {
|
if object.name == expected {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
@@ -106,6 +119,54 @@ pub(crate) fn validate_path(path: &str) -> Result<(), ValidationError> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_interface_reference(
|
||||||
|
reference: &InterfaceReference,
|
||||||
|
) -> Result<(), ValidationError> {
|
||||||
|
validate_path(&reference.scope)
|
||||||
|
.map_err(|error| error.prepend(PathSegment::Field("scope".into())))?;
|
||||||
|
if reference.name.is_empty() {
|
||||||
|
return Err(
|
||||||
|
ValidationError::new(ValidationErrorKind::EmptyInterfaceName)
|
||||||
|
.prepend(PathSegment::Field("name".into())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_interface_scope(
|
||||||
|
reference: &InterfaceReference,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<(), ValidationError> {
|
||||||
|
reference.validate()?;
|
||||||
|
validate_path(path)?;
|
||||||
|
let scope = reference.scope.as_str();
|
||||||
|
if scope == "/"
|
||||||
|
|| path == scope
|
||||||
|
|| path
|
||||||
|
.strip_prefix(scope)
|
||||||
|
.is_some_and(|rest| rest.starts_with('/'))
|
||||||
|
{
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ValidationError::new(
|
||||||
|
ValidationErrorKind::InterfaceScopeMismatch {
|
||||||
|
scope: reference.scope.clone(),
|
||||||
|
path: path.to_owned(),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_call_shape(request: &CallOperationRequest) -> Result<(), ValidationError> {
|
||||||
|
validate_path(&request.target.path)
|
||||||
|
.map_err(|error| error.prepend(PathSegment::Field("target".into())))?;
|
||||||
|
request.interface.reference.validate().map_err(|error| {
|
||||||
|
error
|
||||||
|
.prepend(PathSegment::Field("reference".into()))
|
||||||
|
.prepend(PathSegment::Field("interface".into()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn validate_descriptor(descriptor: &InterfaceDescriptor) -> Result<(), ValidationError> {
|
pub(crate) fn validate_descriptor(descriptor: &InterfaceDescriptor) -> Result<(), ValidationError> {
|
||||||
let declarations = declaration_map(descriptor)?;
|
let declarations = declaration_map(descriptor)?;
|
||||||
|
|
||||||
@@ -582,8 +643,7 @@ pub(crate) fn validate_call(
|
|||||||
descriptor: &InterfaceDescriptor,
|
descriptor: &InterfaceDescriptor,
|
||||||
request: &CallOperationRequest,
|
request: &CallOperationRequest,
|
||||||
) -> Result<(), ValidationError> {
|
) -> Result<(), ValidationError> {
|
||||||
validate_path(&request.target.path)
|
request.validate()?;
|
||||||
.map_err(|error| error.prepend(PathSegment::Field("target".into())))?;
|
|
||||||
validate_arguments(descriptor, &request.operation, &request.arguments)
|
validate_arguments(descriptor, &request.operation, &request.arguments)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -660,11 +720,13 @@ pub(crate) fn validate_fetch_interface_response(
|
|||||||
response: &FetchInterfaceResponse,
|
response: &FetchInterfaceResponse,
|
||||||
request: &FetchInterfaceRequest,
|
request: &FetchInterfaceRequest,
|
||||||
) -> Result<(), ValidationError> {
|
) -> Result<(), ValidationError> {
|
||||||
|
request.validate()?;
|
||||||
|
response.interface.validate()?;
|
||||||
if response.interface != request.interface {
|
if response.interface != request.interface {
|
||||||
return Err(
|
return Err(
|
||||||
ValidationError::new(ValidationErrorKind::InterfaceResponseMismatch {
|
ValidationError::new(ValidationErrorKind::InterfaceResponseMismatch {
|
||||||
expected: request.interface.clone(),
|
expected: Box::new(request.interface.clone()),
|
||||||
actual: response.interface.clone(),
|
actual: Box::new(response.interface.clone()),
|
||||||
})
|
})
|
||||||
.prepend(PathSegment::Field("interface".into())),
|
.prepend(PathSegment::Field("interface".into())),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use wip_protocol::{
|
||||||
|
CallOperationRequest, FetchInterfaceRequest, FetchInterfaceResponse, InterfaceDescriptor,
|
||||||
|
InterfaceReference, InterfaceTarget, Object, ObjectObservation, ObserveRequest,
|
||||||
|
OperationDeclaration, ReturnDeclaration, Target, TypeExpr, ValidationErrorKind,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn reference(scope: &str, name: &str) -> InterfaceReference {
|
||||||
|
InterfaceReference {
|
||||||
|
scope: scope.into(),
|
||||||
|
name: name.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn object(path: &str, interfaces: Vec<InterfaceReference>) -> Object {
|
||||||
|
Object {
|
||||||
|
name: path.rsplit('/').next().unwrap().into(),
|
||||||
|
description: None,
|
||||||
|
interfaces,
|
||||||
|
r#ref: None,
|
||||||
|
validator: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn descriptor() -> InterfaceDescriptor {
|
||||||
|
InterfaceDescriptor {
|
||||||
|
format: wip_protocol::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::Unit,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_is_segment_based_ancestor_or_self_not_indexability() {
|
||||||
|
for (scope, path) in [
|
||||||
|
("/", "/"),
|
||||||
|
("/", "/a/b"),
|
||||||
|
("/a", "/a"),
|
||||||
|
("/a", "/a/b"),
|
||||||
|
("/a/b", "/a/b/c"),
|
||||||
|
] {
|
||||||
|
reference(scope, "read").validate_for_path(path).unwrap();
|
||||||
|
object(path, vec![reference(scope, "read")])
|
||||||
|
.validate_at_path(path)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
for (scope, path) in [
|
||||||
|
("/a", "/ab"),
|
||||||
|
("/a", "/ab/c"),
|
||||||
|
("/a/b", "/a/c"),
|
||||||
|
("/a/b", "/a"),
|
||||||
|
("/a", "/"),
|
||||||
|
] {
|
||||||
|
assert!(matches!(
|
||||||
|
reference(scope, "read")
|
||||||
|
.validate_for_path(path)
|
||||||
|
.unwrap_err()
|
||||||
|
.kind,
|
||||||
|
ValidationErrorKind::InterfaceScopeMismatch { .. }
|
||||||
|
));
|
||||||
|
// Without the placement context, the Object/reference shape is valid.
|
||||||
|
let object = object(path, vec![reference(scope, "read")]);
|
||||||
|
object.validate().unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
object.validate_at_path(path).unwrap_err().kind,
|
||||||
|
ValidationErrorKind::InterfaceScopeMismatch { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shape_validation_does_not_normalize_or_parse_fields() {
|
||||||
|
for scope in ["", "relative", "//a", "/a/", "/a//b", "/a/./b", "/a/../b"] {
|
||||||
|
let reference = reference(scope, "read");
|
||||||
|
assert!(matches!(
|
||||||
|
reference.validate().unwrap_err().kind,
|
||||||
|
ValidationErrorKind::InvalidPath { .. }
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
FetchInterfaceRequest {
|
||||||
|
interface: reference.clone()
|
||||||
|
}
|
||||||
|
.validate()
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(object("/a", vec![reference]).validate().is_err());
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
reference("/", "").validate().unwrap_err().kind,
|
||||||
|
ValidationErrorKind::EmptyInterfaceName
|
||||||
|
);
|
||||||
|
for name in [".", "..", "a/b", "::", "#", "読み::書き#λ", "e\u{301}"] {
|
||||||
|
let reference = reference("/世::界/#/%2F", name);
|
||||||
|
reference.validate().unwrap();
|
||||||
|
assert_eq!(reference.name, name);
|
||||||
|
assert_eq!(reference.scope, "/世::界/#/%2F");
|
||||||
|
}
|
||||||
|
assert_ne!(reference("/", "é"), reference("/", "e\u{301}"));
|
||||||
|
assert_ne!(reference("/A", "read"), reference("/a", "read"));
|
||||||
|
assert_ne!(reference("/%61", "read"), reference("/a", "read"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_membership_and_correlation_use_complete_pair() {
|
||||||
|
let root = reference("/", "read");
|
||||||
|
let scoped = reference("/a", "read");
|
||||||
|
let mut object = object("/a", vec![root.clone(), scoped.clone()]);
|
||||||
|
object.validate_at_path("/a").unwrap();
|
||||||
|
assert!(object.interfaces.contains(&scoped));
|
||||||
|
assert!(!object.interfaces.contains(&reference("/b", "read")));
|
||||||
|
object.interfaces.push(root.clone());
|
||||||
|
assert_eq!(
|
||||||
|
object.validate().unwrap_err().kind,
|
||||||
|
ValidationErrorKind::DuplicateInterface {
|
||||||
|
reference: root.clone()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let request = FetchInterfaceRequest { interface: root };
|
||||||
|
let mut response = FetchInterfaceResponse {
|
||||||
|
interface: scoped,
|
||||||
|
scope_ref: None,
|
||||||
|
descriptor: descriptor(),
|
||||||
|
validator: None,
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
response.validate_for(&request).unwrap_err().kind,
|
||||||
|
ValidationErrorKind::InterfaceResponseMismatch { .. }
|
||||||
|
));
|
||||||
|
response.interface = request.interface.clone();
|
||||||
|
for scope_ref in [None, Some(String::new()), Some("opaque::世界#/%".into())] {
|
||||||
|
response.scope_ref = scope_ref;
|
||||||
|
response.validate_for(&request).unwrap();
|
||||||
|
}
|
||||||
|
response.interface.name.clear();
|
||||||
|
assert_eq!(
|
||||||
|
response.validate_for(&request).unwrap_err().kind,
|
||||||
|
ValidationErrorKind::EmptyInterfaceName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn observe_checks_scope_at_every_node_including_depth_boundary() {
|
||||||
|
let request = ObserveRequest {
|
||||||
|
path: "/a".into(),
|
||||||
|
depth: 1,
|
||||||
|
};
|
||||||
|
let mut response = ObjectObservation {
|
||||||
|
object: object("/a", vec![reference("/a", "read")]),
|
||||||
|
children: Some(vec![ObjectObservation {
|
||||||
|
object: object("/a/b", vec![reference("/a/b", "read")]),
|
||||||
|
children: None,
|
||||||
|
}]),
|
||||||
|
};
|
||||||
|
request.validate_response(&response).unwrap();
|
||||||
|
response.children.as_mut().unwrap()[0].object.interfaces[0].scope = "/a/c".into();
|
||||||
|
assert!(matches!(
|
||||||
|
request.validate_response(&response).unwrap_err().kind,
|
||||||
|
ValidationErrorKind::InterfaceScopeMismatch { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fetch_needs_no_target_path_and_call_separates_shape_from_scope_selection() {
|
||||||
|
let reference = reference("/some/unrelated/scope", "read");
|
||||||
|
let fetch = FetchInterfaceRequest {
|
||||||
|
interface: reference.clone(),
|
||||||
|
};
|
||||||
|
fetch.validate().unwrap();
|
||||||
|
FetchInterfaceResponse {
|
||||||
|
interface: reference.clone(),
|
||||||
|
scope_ref: None,
|
||||||
|
descriptor: descriptor(),
|
||||||
|
validator: None,
|
||||||
|
}
|
||||||
|
.validate_for(&fetch)
|
||||||
|
.unwrap();
|
||||||
|
let mut call = CallOperationRequest {
|
||||||
|
target: Target {
|
||||||
|
path: "/a".into(),
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
interface: InterfaceTarget {
|
||||||
|
reference,
|
||||||
|
scope_ref: Some("opaque".into()),
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
operation: "run".into(),
|
||||||
|
arguments: BTreeMap::new(),
|
||||||
|
};
|
||||||
|
call.validate().unwrap();
|
||||||
|
call.validate_with(&descriptor()).unwrap();
|
||||||
|
// This stage is Host InterfaceMismatch, after NotFound, not shape InvalidRequest.
|
||||||
|
assert!(matches!(
|
||||||
|
call.interface
|
||||||
|
.reference
|
||||||
|
.validate_for_path(&call.target.path)
|
||||||
|
.unwrap_err()
|
||||||
|
.kind,
|
||||||
|
ValidationErrorKind::InterfaceScopeMismatch { .. }
|
||||||
|
));
|
||||||
|
call.interface.reference.name.clear();
|
||||||
|
assert_eq!(
|
||||||
|
call.validate().unwrap_err().kind,
|
||||||
|
ValidationErrorKind::EmptyInterfaceName
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
call.validate_with(&descriptor()).unwrap_err().kind,
|
||||||
|
ValidationErrorKind::EmptyInterfaceName
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,12 +3,19 @@ use std::collections::BTreeMap;
|
|||||||
use wip_protocol::{
|
use wip_protocol::{
|
||||||
CallOperationRequest, CallOperationResponse, EnumCase, FetchInterfaceRequest,
|
CallOperationRequest, CallOperationResponse, EnumCase, FetchInterfaceRequest,
|
||||||
FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor,
|
FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor,
|
||||||
InterfaceTarget, MAX_SAFE_INTEGER, NameNamespace, Object, ObjectObservation, ObserveRequest,
|
InterfaceReference, InterfaceTarget, MAX_SAFE_INTEGER, NameNamespace, Object,
|
||||||
OperationDeclaration, ParameterDeclaration, PathSegment, ProtocolError, ProtocolErrorCode,
|
ObjectObservation, ObserveRequest, OperationDeclaration, ParameterDeclaration, PathSegment,
|
||||||
ProtocolInteraction, ProtocolResult, ReturnDeclaration, Target, TypeDeclaration, TypeExpr,
|
ProtocolError, ProtocolErrorCode, ProtocolInteraction, ProtocolResult, ReturnDeclaration,
|
||||||
UnionCase, ValidationErrorKind, Value, ValueKind,
|
Target, TypeDeclaration, TypeExpr, UnionCase, ValidationErrorKind, Value, ValueKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
fn reference(scope: &str, name: &str) -> InterfaceReference {
|
||||||
|
InterfaceReference {
|
||||||
|
scope: scope.into(),
|
||||||
|
name: name.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn record(entries: impl IntoIterator<Item = (&'static str, Value)>) -> Value {
|
fn record(entries: impl IntoIterator<Item = (&'static str, Value)>) -> Value {
|
||||||
Value::Record(
|
Value::Record(
|
||||||
entries
|
entries
|
||||||
@@ -135,7 +142,7 @@ fn object(name: &str) -> Object {
|
|||||||
Object {
|
Object {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
description: Some(format!("Object {name}")),
|
description: Some(format!("Object {name}")),
|
||||||
interfaces: vec!["catalog-v1".into(), "search-v2".into()],
|
interfaces: vec![reference("/", "catalog-v1"), reference("/", "search-v2")],
|
||||||
r#ref: Some("opaque-object-ref".into()),
|
r#ref: Some("opaque-object-ref".into()),
|
||||||
validator: Some(vec![1, 2, 3]),
|
validator: Some(vec![1, 2, 3]),
|
||||||
}
|
}
|
||||||
@@ -146,16 +153,19 @@ fn object_fields_have_canonical_identity_and_ordered_set_semantics() {
|
|||||||
let object = object("article");
|
let object = object("article");
|
||||||
object.validate().unwrap();
|
object.validate().unwrap();
|
||||||
object.validate_at_path("/articles/article").unwrap();
|
object.validate_at_path("/articles/article").unwrap();
|
||||||
assert_eq!(object.interfaces, ["catalog-v1", "search-v2"]);
|
assert_eq!(
|
||||||
|
object.interfaces,
|
||||||
|
[reference("/", "catalog-v1"), reference("/", "search-v2")]
|
||||||
|
);
|
||||||
|
|
||||||
let duplicate = Object {
|
let duplicate = Object {
|
||||||
interfaces: vec!["catalog-v1".into(), "catalog-v1".into()],
|
interfaces: vec![reference("/", "catalog-v1"), reference("/", "catalog-v1")],
|
||||||
..object.clone()
|
..object.clone()
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
duplicate.validate().unwrap_err().kind,
|
duplicate.validate().unwrap_err().kind,
|
||||||
ValidationErrorKind::DuplicateInterface {
|
ValidationErrorKind::DuplicateInterface {
|
||||||
reference: "catalog-v1".into()
|
reference: reference("/", "catalog-v1")
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -574,7 +584,8 @@ fn calls_validate_canonical_paths_named_arguments_and_unit_results() {
|
|||||||
validator: Some(vec![1]),
|
validator: Some(vec![1]),
|
||||||
},
|
},
|
||||||
interface: InterfaceTarget {
|
interface: InterfaceTarget {
|
||||||
reference: "catalog-v1".into(),
|
scope_ref: None,
|
||||||
|
reference: reference("/", "catalog-v1"),
|
||||||
validator: Some(vec![2]),
|
validator: Some(vec![2]),
|
||||||
},
|
},
|
||||||
operation: "publish".into(),
|
operation: "publish".into(),
|
||||||
@@ -659,17 +670,18 @@ fn observe_and_fetch_interface_dtos_validate_request_response_correspondence() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let request = FetchInterfaceRequest {
|
let request = FetchInterfaceRequest {
|
||||||
interface: "catalog-v1".into(),
|
interface: reference("/", "catalog-v1"),
|
||||||
};
|
};
|
||||||
let response = FetchInterfaceResponse {
|
let response = FetchInterfaceResponse {
|
||||||
interface: "catalog-v1".into(),
|
scope_ref: None,
|
||||||
|
interface: reference("/", "catalog-v1"),
|
||||||
descriptor: descriptor(),
|
descriptor: descriptor(),
|
||||||
validator: Some(vec![9]),
|
validator: Some(vec![9]),
|
||||||
};
|
};
|
||||||
response.validate_for(&request).unwrap();
|
response.validate_for(&request).unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
FetchInterfaceResponse {
|
FetchInterfaceResponse {
|
||||||
interface: "other".into(),
|
interface: reference("/", "other"),
|
||||||
..response
|
..response
|
||||||
}
|
}
|
||||||
.validate_for(&request)
|
.validate_for(&request)
|
||||||
@@ -847,7 +859,7 @@ fn invalid_success_payloads_remain_client_local_validation_failures() {
|
|||||||
));
|
));
|
||||||
|
|
||||||
let interface_request = FetchInterfaceRequest {
|
let interface_request = FetchInterfaceRequest {
|
||||||
interface: "catalog-v1".into(),
|
interface: reference("/", "catalog-v1"),
|
||||||
};
|
};
|
||||||
let mut invalid_descriptor = descriptor();
|
let mut invalid_descriptor = descriptor();
|
||||||
invalid_descriptor
|
invalid_descriptor
|
||||||
@@ -856,6 +868,7 @@ fn invalid_success_payloads_remain_client_local_validation_failures() {
|
|||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
FetchInterfaceResponse {
|
FetchInterfaceResponse {
|
||||||
interface: interface_request.interface.clone(),
|
interface: interface_request.interface.clone(),
|
||||||
|
scope_ref: None,
|
||||||
descriptor: invalid_descriptor,
|
descriptor: invalid_descriptor,
|
||||||
validator: None,
|
validator: None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wip-public-api-example"
|
name = "wip-public-api-example"
|
||||||
version = "0.1.0"
|
version.workspace = true
|
||||||
edition = "2024"
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
description = "Public-only end-to-end conformance consumer for the WIP Rust APIs"
|
description = "Public-only end-to-end conformance consumer for the WIP Rust APIs"
|
||||||
license = "MIT OR Apache-2.0"
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
wip-host = { path = "../wip-host" }
|
wip-host.workspace = true
|
||||||
wip-interface = { path = "../wip-interface" }
|
wip-interface.workspace = true
|
||||||
wip-protocol = { path = "../wip-protocol" }
|
wip-protocol.workspace = true
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
//!
|
//!
|
||||||
//! The canonical API targets operations by `target.path` rather than a mandatory
|
//! The canonical API targets operations by `target.path` rather than a mandatory
|
||||||
//! object identifier. Object `ref` and object/interface validators are separate,
|
//! object identifier. Object `ref` and object/interface validators are separate,
|
||||||
//! optional observation fields. Interface references are opaque values observed
|
//! optional observation fields. Interface references are structured scope/name values observed
|
||||||
//! separately from descriptors, and descriptors contain neither a Rust trait
|
//! separately from descriptors, and descriptors contain neither a Rust trait
|
||||||
//! name nor an opaque-reference `name` field. Application records, enums,
|
//! name nor an opaque-reference `name` field. Application records, enums,
|
||||||
//! unions, entry paths, and JSON all encode through the schema-neutral [`Value`]
|
//! unions, entry paths, and JSON all encode through the schema-neutral [`Value`]
|
||||||
@@ -46,7 +46,7 @@ use wip_host::{
|
|||||||
};
|
};
|
||||||
use wip_interface as wip;
|
use wip_interface as wip;
|
||||||
use wip_interface::WipType;
|
use wip_interface::WipType;
|
||||||
use wip_protocol::{ProtocolError, ProtocolErrorCode, Value};
|
use wip_protocol::{InterfaceReference, ProtocolError, ProtocolErrorCode, Value};
|
||||||
|
|
||||||
const OBJECT_PATH: &str = "/article-service";
|
const OBJECT_PATH: &str = "/article-service";
|
||||||
const OBJECT_VALIDATOR: &[u8] = b"object-v1";
|
const OBJECT_VALIDATOR: &[u8] = b"object-v1";
|
||||||
@@ -134,12 +134,12 @@ pub struct DemoReport {
|
|||||||
pub object_reference: Option<String>,
|
pub object_reference: Option<String>,
|
||||||
/// Optional object-state validator.
|
/// Optional object-state validator.
|
||||||
pub object_validator: Option<Vec<u8>>,
|
pub object_validator: Option<Vec<u8>>,
|
||||||
/// Opaque interface references published by the object, in exposure order.
|
/// Structured interface references published by the object, in exposure order.
|
||||||
pub published_interfaces: Vec<String>,
|
pub published_interfaces: Vec<InterfaceReference>,
|
||||||
/// Opaque reference assigned to the catalog descriptor.
|
/// Root-scoped reference assigned to the catalog descriptor.
|
||||||
pub catalog_interface_reference: String,
|
pub catalog_interface_reference: InterfaceReference,
|
||||||
/// Opaque reference assigned to the audit descriptor.
|
/// Root-scoped reference assigned to the audit descriptor.
|
||||||
pub audit_interface_reference: String,
|
pub audit_interface_reference: InterfaceReference,
|
||||||
/// Descriptor format observed separately for the catalog reference.
|
/// Descriptor format observed separately for the catalog reference.
|
||||||
pub catalog_descriptor_format: String,
|
pub catalog_descriptor_format: String,
|
||||||
/// Descriptor format observed separately for the audit reference.
|
/// Descriptor format observed separately for the audit reference.
|
||||||
@@ -296,7 +296,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
|||||||
validator: Some(b"stale-object".to_vec()),
|
validator: Some(b"stale-object".to_vec()),
|
||||||
},
|
},
|
||||||
interface: InterfaceTarget {
|
interface: InterfaceTarget {
|
||||||
reference: diagnostics.reference().as_str().to_owned(),
|
scope_ref: None,
|
||||||
|
reference: diagnostics.reference().protocol_reference(),
|
||||||
validator: Some(b"stale-interface".to_vec()),
|
validator: Some(b"stale-interface".to_vec()),
|
||||||
},
|
},
|
||||||
operation: "missing".into(),
|
operation: "missing".into(),
|
||||||
@@ -319,7 +320,10 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
|||||||
.code;
|
.code;
|
||||||
|
|
||||||
let interface_not_found = registry
|
let interface_not_found = registry
|
||||||
.fetch_interface("missing-interface")
|
.fetch_interface(&InterfaceReference {
|
||||||
|
scope: "/".into(),
|
||||||
|
name: "missing-interface".into(),
|
||||||
|
})
|
||||||
.expect_err("unknown interface references must not resolve")
|
.expect_err("unknown interface references must not resolve")
|
||||||
.code;
|
.code;
|
||||||
|
|
||||||
@@ -330,7 +334,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
|||||||
validator: Some(b"stale-object".to_vec()),
|
validator: Some(b"stale-object".to_vec()),
|
||||||
},
|
},
|
||||||
interface: InterfaceTarget {
|
interface: InterfaceTarget {
|
||||||
reference: diagnostics.reference().as_str().to_owned(),
|
scope_ref: None,
|
||||||
|
reference: diagnostics.reference().protocol_reference(),
|
||||||
validator: Some(b"stale-interface".to_vec()),
|
validator: Some(b"stale-interface".to_vec()),
|
||||||
},
|
},
|
||||||
operation: "missing".into(),
|
operation: "missing".into(),
|
||||||
@@ -345,8 +350,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
|||||||
object_reference: object.r#ref,
|
object_reference: object.r#ref,
|
||||||
object_validator: object.validator,
|
object_validator: object.validator,
|
||||||
published_interfaces: object.interfaces,
|
published_interfaces: object.interfaces,
|
||||||
catalog_interface_reference: catalog.reference().as_str().to_owned(),
|
catalog_interface_reference: catalog.reference().protocol_reference(),
|
||||||
audit_interface_reference: audit.reference().as_str().to_owned(),
|
audit_interface_reference: audit.reference().protocol_reference(),
|
||||||
catalog_descriptor_format: catalog_response.descriptor.format,
|
catalog_descriptor_format: catalog_response.descriptor.format,
|
||||||
audit_descriptor_format: audit_response.descriptor.format,
|
audit_descriptor_format: audit_response.descriptor.format,
|
||||||
published,
|
published,
|
||||||
@@ -412,7 +417,8 @@ fn request(
|
|||||||
validator: Some(OBJECT_VALIDATOR.to_vec()),
|
validator: Some(OBJECT_VALIDATOR.to_vec()),
|
||||||
},
|
},
|
||||||
interface: InterfaceTarget {
|
interface: InterfaceTarget {
|
||||||
reference: interface.as_str().to_owned(),
|
scope_ref: None,
|
||||||
|
reference: interface.protocol_reference(),
|
||||||
validator: interface_validator.map(<[u8]>::to_vec),
|
validator: interface_validator.map(<[u8]>::to_vec),
|
||||||
},
|
},
|
||||||
operation: operation.into(),
|
operation: operation.into(),
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ fn object_fields_keep_path_context_and_optional_ref_separate() {
|
|||||||
let object = Object {
|
let object = Object {
|
||||||
name: "article".into(),
|
name: "article".into(),
|
||||||
description: None,
|
description: None,
|
||||||
interfaces: vec!["catalog-ref".into(), "audit-ref".into()],
|
interfaces: vec![root_reference("catalog-ref"), root_reference("audit-ref")],
|
||||||
r#ref: None,
|
r#ref: None,
|
||||||
validator: None,
|
validator: None,
|
||||||
};
|
};
|
||||||
@@ -105,7 +105,10 @@ fn object_fields_keep_path_context_and_optional_ref_separate() {
|
|||||||
object.validate_at_path("/library/article").unwrap();
|
object.validate_at_path("/library/article").unwrap();
|
||||||
assert_eq!(object.name, "article");
|
assert_eq!(object.name, "article");
|
||||||
assert_eq!(object.r#ref, None);
|
assert_eq!(object.r#ref, None);
|
||||||
assert_eq!(object.interfaces, ["catalog-ref", "audit-ref"]);
|
assert_eq!(
|
||||||
|
object.interfaces,
|
||||||
|
[root_reference("catalog-ref"), root_reference("audit-ref")]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -244,3 +247,10 @@ fn assert_schema_neutral(value: &Value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||||
|
wip_protocol::InterfaceReference {
|
||||||
|
scope: "/".into(),
|
||||||
|
name: name.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wip-tui"
|
name = "wip-tui"
|
||||||
version = "0.1.0"
|
version.workspace = true
|
||||||
edition = "2024"
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
description = "Interactive terminal client for exploring and operating WIP Worldspaces"
|
description = "Interactive terminal client for exploring and operating WIP Worldspaces"
|
||||||
license = "MIT OR Apache-2.0"
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
@@ -13,10 +15,10 @@ crossterm = "0.28.1"
|
|||||||
ratatui = "0.29.0"
|
ratatui = "0.29.0"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
ureq = "3.1.2"
|
ureq = "3.1.2"
|
||||||
wip-client = { path = "../wip-client" }
|
wip-client.workspace = true
|
||||||
wip-http = { path = "../wip-http" }
|
wip-http.workspace = true
|
||||||
wip-protocol = { path = "../wip-protocol" }
|
wip-protocol.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
wip-fs-server = { path = "../wip-fs-server" }
|
wip-fs-server.workspace = true
|
||||||
|
|||||||
@@ -64,6 +64,38 @@ enter `contents` as RFC 4648 base64—for example, `dXBkYXRlZA==` for
|
|||||||
`updated`—then review the target Entry, interface reference, operation, and value
|
`updated`—then review the target Entry, interface reference, operation, and value
|
||||||
on the confirmation screen before pressing Enter again.
|
on the confirmation screen before pressing Enter again.
|
||||||
|
|
||||||
|
## Scoped reference formatting
|
||||||
|
|
||||||
|
`wip_tui::reference::{format_interface_reference, format_qualified_operation}`
|
||||||
|
provides labels for the existing UI from `InterfaceReference` and a separate
|
||||||
|
operation name, following `4.2-text-view.md` reference notation (`6086f3c`).
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wip_protocol::InterfaceReference;
|
||||||
|
use wip_tui::reference::{format_interface_reference, format_qualified_operation};
|
||||||
|
|
||||||
|
let reference = InterfaceReference { scope: "/github".into(), name: "issue#read".into() };
|
||||||
|
assert_eq!(format_interface_reference(&reference)?, "/github::\"issue#read\"");
|
||||||
|
assert_eq!(format_qualified_operation(&reference, "read#raw")?,
|
||||||
|
"/github::\"issue#read\"#\"read#raw\"");
|
||||||
|
# Ok::<(), wip_protocol::ValidationError>(())
|
||||||
|
```
|
||||||
|
|
||||||
|
Both return `Result<String, ValidationError>` and reject invalid reference shape,
|
||||||
|
including relative/omitted scope. Root is explicit `/`. Non-simple components use
|
||||||
|
JSON string quoting with additional DEL/C1, Unicode line/paragraph separator and
|
||||||
|
bidi-control escapes. Input values are borrowed and never normalized, case-folded,
|
||||||
|
URL-encoded or split on delimiters. Ref, validators and `scope_ref` do not enter
|
||||||
|
these labels. The output has no extra outer signature string quoting.
|
||||||
|
|
||||||
|
Keep selection, cache and calls keyed by structured references and operation
|
||||||
|
names, never display text. No parser, scope inference, identity reconstruction,
|
||||||
|
Object/Interface signature renderer or new crate is introduced. Membership,
|
||||||
|
ancestry, authorization and freshness remain runtime concerns. Screen integration
|
||||||
|
is follow-up T-23; the current bounded screen/value/diagnostic rendering is
|
||||||
|
unchanged. At integration, apply existing UI layout bounds to display labels
|
||||||
|
without feeding shortened labels back into identity or calls.
|
||||||
|
|
||||||
## Keyboard bindings
|
## Keyboard bindings
|
||||||
|
|
||||||
All required actions are keyboard-accessible.
|
All required actions are keyboard-accessible.
|
||||||
@@ -96,8 +128,10 @@ a second explicit Enter (or `e`) to execute once. There is no operation auto-ret
|
|||||||
interfaces, and freshness. Validators, credentials, Host internals, and backend
|
interfaces, and freshness. Validators, credentials, Host internals, and backend
|
||||||
paths are deliberately hidden. If current metadata disappears, old text is not
|
paths are deliberately hidden. If current metadata disappears, old text is not
|
||||||
presented as current.
|
presented as current.
|
||||||
- **Interfaces / Operations** identifies every operation by `(interface reference,
|
- **Interfaces / Operations** identifies every operation by `(interface scope,
|
||||||
operation name)`. Descriptor lookahead follows fresh visible Objects, shares the
|
interface name, operation name)` and explicitly displays `scope=…` and `name=…`.
|
||||||
|
Structured references remain intact in selection, acquisition, and call state;
|
||||||
|
display labels are never parsed back into references. Descriptor lookahead follows fresh visible Objects, shares the
|
||||||
client's in-flight/cache bounds, and never retries retained failures implicitly.
|
client's in-flight/cache bounds, and never retries retained failures implicitly.
|
||||||
The pane shows descriptor documentation, ordered parameters, required/optional
|
The pane shows descriptor documentation, ordered parameters, required/optional
|
||||||
status, and result types.
|
status, and result types.
|
||||||
|
|||||||
+71
-16
@@ -3,7 +3,7 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
use wip_client::{InterfaceObservation, ObjectObservation, ObservationState, TreeObservation};
|
use wip_client::{InterfaceObservation, ObjectObservation, ObservationState, TreeObservation};
|
||||||
use wip_protocol::{InterfaceDescriptor, Object, Value};
|
use wip_protocol::{InterfaceDescriptor, InterfaceReference, Object, Value};
|
||||||
|
|
||||||
use crate::editor::ArgumentEditor;
|
use crate::editor::ArgumentEditor;
|
||||||
|
|
||||||
@@ -139,8 +139,8 @@ impl EntryView {
|
|||||||
/// Cached interface descriptor state used by presentation.
|
/// Cached interface descriptor state used by presentation.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct InterfaceView {
|
pub struct InterfaceView {
|
||||||
/// Opaque interface reference.
|
/// Structured interface reference.
|
||||||
pub reference: String,
|
pub reference: InterfaceReference,
|
||||||
/// Descriptor when accepted by the client runtime.
|
/// Descriptor when accepted by the client runtime.
|
||||||
pub descriptor: Option<InterfaceDescriptor>,
|
pub descriptor: Option<InterfaceDescriptor>,
|
||||||
/// Descriptor observation state.
|
/// Descriptor observation state.
|
||||||
@@ -187,13 +187,13 @@ pub enum InFlightKind {
|
|||||||
/// Tree expansion or refresh.
|
/// Tree expansion or refresh.
|
||||||
Tree(String),
|
Tree(String),
|
||||||
/// Descriptor fetch.
|
/// Descriptor fetch.
|
||||||
Interface(String),
|
Interface(InterfaceReference),
|
||||||
/// Explicit operation call.
|
/// Explicit operation call.
|
||||||
Operation {
|
Operation {
|
||||||
/// Target path.
|
/// Target path.
|
||||||
path: String,
|
path: String,
|
||||||
/// Interface reference.
|
/// Interface reference.
|
||||||
interface: String,
|
interface: InterfaceReference,
|
||||||
/// Operation name.
|
/// Operation name.
|
||||||
operation: String,
|
operation: String,
|
||||||
/// Whether the transport dispatch boundary was crossed.
|
/// Whether the transport dispatch boundary was crossed.
|
||||||
@@ -241,13 +241,13 @@ pub enum Action {
|
|||||||
path: String,
|
path: String,
|
||||||
},
|
},
|
||||||
/// Retrieve one interface descriptor.
|
/// Retrieve one interface descriptor.
|
||||||
FetchInterface(String),
|
FetchInterface(InterfaceReference),
|
||||||
/// Execute only after explicit confirmation.
|
/// Execute only after explicit confirmation.
|
||||||
Execute {
|
Execute {
|
||||||
/// Entry target.
|
/// Entry target.
|
||||||
path: String,
|
path: String,
|
||||||
/// Interface reference.
|
/// Interface reference.
|
||||||
interface: String,
|
interface: InterfaceReference,
|
||||||
/// Operation name.
|
/// Operation name.
|
||||||
operation: String,
|
operation: String,
|
||||||
/// Descriptor-validated arguments.
|
/// Descriptor-validated arguments.
|
||||||
@@ -276,8 +276,8 @@ pub struct App {
|
|||||||
pub selected_path: String,
|
pub selected_path: String,
|
||||||
/// Focused pane.
|
/// Focused pane.
|
||||||
pub focus: Focus,
|
pub focus: Focus,
|
||||||
/// Interface descriptor views by opaque reference.
|
/// Interface descriptor views by structured reference.
|
||||||
pub interfaces: BTreeMap<String, InterfaceView>,
|
pub interfaces: BTreeMap<InterfaceReference, InterfaceView>,
|
||||||
/// Selected row in the flattened interface/operation view.
|
/// Selected row in the flattened interface/operation view.
|
||||||
pub operation_cursor: usize,
|
pub operation_cursor: usize,
|
||||||
/// Selected recent-history row.
|
/// Selected recent-history row.
|
||||||
@@ -615,7 +615,7 @@ impl App {
|
|||||||
/// Returns interface references advertised by fresh visible Objects in display
|
/// Returns interface references advertised by fresh visible Objects in display
|
||||||
/// and per-Object declaration order, with duplicates removed.
|
/// and per-Object declaration order, with duplicates removed.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn visible_interface_references(&self) -> Vec<String> {
|
pub fn visible_interface_references(&self) -> Vec<InterfaceReference> {
|
||||||
let mut references = Vec::new();
|
let mut references = Vec::new();
|
||||||
let mut seen = BTreeSet::new();
|
let mut seen = BTreeSet::new();
|
||||||
for path in self.visible_paths() {
|
for path in self.visible_paths() {
|
||||||
@@ -716,7 +716,7 @@ impl App {
|
|||||||
.or_insert_with(|| EntryView::placeholder(path))
|
.or_insert_with(|| EntryView::placeholder(path))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_interface(&mut self, reference: &str) -> &mut InterfaceView {
|
fn ensure_interface(&mut self, reference: &InterfaceReference) -> &mut InterfaceView {
|
||||||
if !self.interfaces.contains_key(reference)
|
if !self.interfaces.contains_key(reference)
|
||||||
&& self.interfaces.len() >= MAX_PRESENTATION_INTERFACES
|
&& self.interfaces.len() >= MAX_PRESENTATION_INTERFACES
|
||||||
{
|
{
|
||||||
@@ -757,7 +757,7 @@ impl App {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct OperationRow {
|
pub struct OperationRow {
|
||||||
/// Interface reference, which participates in operation identity.
|
/// Interface reference, which participates in operation identity.
|
||||||
pub interface: String,
|
pub interface: InterfaceReference,
|
||||||
/// Operation name, or `None` for the interface heading.
|
/// Operation name, or `None` for the interface heading.
|
||||||
pub operation: Option<String>,
|
pub operation: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -773,6 +773,12 @@ pub fn parent_path(path: &str) -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||||
|
wip_protocol::InterfaceReference {
|
||||||
|
scope: "/".into(),
|
||||||
|
name: name.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
use super::*;
|
use super::*;
|
||||||
use wip_protocol::{INTERFACE_FORMAT_V1, OperationDeclaration, ReturnDeclaration, TypeExpr};
|
use wip_protocol::{INTERFACE_FORMAT_V1, OperationDeclaration, ReturnDeclaration, TypeExpr};
|
||||||
|
|
||||||
@@ -780,7 +786,10 @@ mod tests {
|
|||||||
Object {
|
Object {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
description: Some(format!("{name} description")),
|
description: Some(format!("{name} description")),
|
||||||
interfaces: interfaces.iter().map(|value| (*value).into()).collect(),
|
interfaces: interfaces
|
||||||
|
.iter()
|
||||||
|
.map(|value| root_reference(value))
|
||||||
|
.collect(),
|
||||||
r#ref: None,
|
r#ref: None,
|
||||||
validator: None,
|
validator: None,
|
||||||
}
|
}
|
||||||
@@ -846,7 +855,51 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
app.visible_interface_references(),
|
app.visible_interface_references(),
|
||||||
["root", "shared", "child"]
|
[
|
||||||
|
root_reference("root"),
|
||||||
|
root_reference("shared"),
|
||||||
|
root_reference("child")
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn presentation_retains_equal_names_in_distinct_scopes() {
|
||||||
|
let mut app = App::new("https://example.test/wip");
|
||||||
|
let root = root_reference("shared#::操作");
|
||||||
|
let local = InterfaceReference {
|
||||||
|
scope: "/a".into(),
|
||||||
|
name: root.name.clone(),
|
||||||
|
};
|
||||||
|
let mut value = object("a", &[]);
|
||||||
|
value.interfaces = vec![root.clone(), local.clone()];
|
||||||
|
app.navigate_entry("/a");
|
||||||
|
app.apply_object(ObjectObservation {
|
||||||
|
path: "/a".into(),
|
||||||
|
object: Some(value),
|
||||||
|
validator: None,
|
||||||
|
state: ObservationState::Fresh,
|
||||||
|
});
|
||||||
|
for reference in [&root, &local] {
|
||||||
|
app.apply_interface(InterfaceObservation {
|
||||||
|
reference: reference.clone(),
|
||||||
|
descriptor: None,
|
||||||
|
validator: None,
|
||||||
|
scope_ref: None,
|
||||||
|
state: ObservationState::Fresh,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert_eq!(app.interfaces.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
app.visible_interface_references(),
|
||||||
|
vec![root.clone(), local.clone()]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
app.operation_rows()
|
||||||
|
.iter()
|
||||||
|
.map(|row| row.interface.clone())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![root, local]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -874,7 +927,8 @@ mod tests {
|
|||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
app.apply_interface(InterfaceObservation {
|
app.apply_interface(InterfaceObservation {
|
||||||
reference: "iface".into(),
|
scope_ref: None,
|
||||||
|
reference: root_reference("iface"),
|
||||||
descriptor: Some(descriptor),
|
descriptor: Some(descriptor),
|
||||||
validator: None,
|
validator: None,
|
||||||
state: ObservationState::Fresh,
|
state: ObservationState::Fresh,
|
||||||
@@ -903,7 +957,8 @@ mod tests {
|
|||||||
|
|
||||||
for index in 0..(MAX_PRESENTATION_INTERFACES + 50) {
|
for index in 0..(MAX_PRESENTATION_INTERFACES + 50) {
|
||||||
app.apply_interface(InterfaceObservation {
|
app.apply_interface(InterfaceObservation {
|
||||||
reference: format!("interface-{index}"),
|
scope_ref: None,
|
||||||
|
reference: root_reference(&format!("interface-{index}")),
|
||||||
descriptor: None,
|
descriptor: None,
|
||||||
validator: None,
|
validator: None,
|
||||||
state: ObservationState::Fresh,
|
state: ObservationState::Fresh,
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ use std::collections::BTreeMap;
|
|||||||
|
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use base64::engine::general_purpose::STANDARD;
|
use base64::engine::general_purpose::STANDARD;
|
||||||
use wip_protocol::{InterfaceDescriptor, OperationDeclaration, TypeExpr, Value};
|
use wip_protocol::{
|
||||||
|
InterfaceDescriptor, InterfaceReference, OperationDeclaration, TypeExpr, Value,
|
||||||
|
};
|
||||||
|
|
||||||
const MAX_FIELD_INPUT_BYTES: usize = 256 * 1024;
|
const MAX_FIELD_INPUT_BYTES: usize = 256 * 1024;
|
||||||
|
|
||||||
@@ -30,8 +32,8 @@ pub struct InvalidArguments;
|
|||||||
/// Pure, terminal-independent editor built from an operation descriptor.
|
/// Pure, terminal-independent editor built from an operation descriptor.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ArgumentEditor {
|
pub struct ArgumentEditor {
|
||||||
/// Opaque interface reference selected for the call.
|
/// Structured interface reference selected for the call.
|
||||||
pub interface: String,
|
pub interface: InterfaceReference,
|
||||||
/// Selected operation declaration.
|
/// Selected operation declaration.
|
||||||
pub operation: OperationDeclaration,
|
pub operation: OperationDeclaration,
|
||||||
/// Complete descriptor used to resolve named types.
|
/// Complete descriptor used to resolve named types.
|
||||||
@@ -46,7 +48,7 @@ impl ArgumentEditor {
|
|||||||
/// Builds an editor whose fields preserve descriptor order.
|
/// Builds an editor whose fields preserve descriptor order.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
interface: impl Into<String>,
|
interface: InterfaceReference,
|
||||||
descriptor: InterfaceDescriptor,
|
descriptor: InterfaceDescriptor,
|
||||||
operation: OperationDeclaration,
|
operation: OperationDeclaration,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
@@ -66,7 +68,7 @@ impl ArgumentEditor {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Self {
|
Self {
|
||||||
interface: interface.into(),
|
interface,
|
||||||
operation,
|
operation,
|
||||||
descriptor,
|
descriptor,
|
||||||
fields,
|
fields,
|
||||||
@@ -466,6 +468,12 @@ fn json_to_untyped(json: &serde_json::Value, field: &str) -> Result<Value, Strin
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||||
|
wip_protocol::InterfaceReference {
|
||||||
|
scope: "/".into(),
|
||||||
|
name: name.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
use super::*;
|
use super::*;
|
||||||
use wip_protocol::{
|
use wip_protocol::{
|
||||||
EnumCase, FieldDeclaration, INTERFACE_FORMAT_V1, ParameterDeclaration, ReturnDeclaration,
|
EnumCase, FieldDeclaration, INTERFACE_FORMAT_V1, ParameterDeclaration, ReturnDeclaration,
|
||||||
@@ -560,7 +568,7 @@ mod tests {
|
|||||||
}],
|
}],
|
||||||
);
|
);
|
||||||
let operation = descriptor.operations[0].clone();
|
let operation = descriptor.operations[0].clone();
|
||||||
let mut editor = ArgumentEditor::new("test", descriptor, operation);
|
let mut editor = ArgumentEditor::new(root_reference("test"), descriptor, operation);
|
||||||
let inputs = [
|
let inputs = [
|
||||||
"null",
|
"null",
|
||||||
"true",
|
"true",
|
||||||
@@ -595,7 +603,7 @@ mod tests {
|
|||||||
vec![],
|
vec![],
|
||||||
);
|
);
|
||||||
let operation = descriptor.operations[0].clone();
|
let operation = descriptor.operations[0].clone();
|
||||||
let mut editor = ArgumentEditor::new("test", descriptor, operation);
|
let mut editor = ArgumentEditor::new(root_reference("test"), descriptor, operation);
|
||||||
editor.fields[1].input = "not-a-bool".into();
|
editor.fields[1].input = "not-a-bool".into();
|
||||||
assert_eq!(editor.validate(), Err(InvalidArguments));
|
assert_eq!(editor.validate(), Err(InvalidArguments));
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod editor;
|
pub mod editor;
|
||||||
|
pub mod reference;
|
||||||
pub mod render;
|
pub mod render;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
//! Scoped Interface/Operation labels for the existing TUI.
|
||||||
|
//!
|
||||||
|
//! Follows the reference notation and safety escapes in WIP `4.2-text-view.md`.
|
||||||
|
//! These are display labels, not wire values, cache keys or call targets. Keep
|
||||||
|
//! [`InterfaceReference`] and operation names structured for selection and calls;
|
||||||
|
//! do not split or reparse labels. No Object/Interface signature renderer or
|
||||||
|
//! shorthand input parser is provided. Screen integration and bounded layout
|
||||||
|
//! remain separate from formatting.
|
||||||
|
//!
|
||||||
|
//! Scope is always explicit and canonical absolute. Adapters accepting omitted
|
||||||
|
//! scope must resolve it with an explicit context at their input boundary, never
|
||||||
|
//! default to root, infer from a target path or search ancestors. Membership,
|
||||||
|
//! ancestry and authorization are separate checks, not claims made by a label.
|
||||||
|
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
use wip_protocol::{InterfaceReference, ValidationError};
|
||||||
|
|
||||||
|
/// Formats a complete `scope::interface` label from structured values.
|
||||||
|
///
|
||||||
|
/// Validates reference shape (including canonical absolute scope), then quotes
|
||||||
|
/// components as needed. There is no outer signature JSON string layer. The
|
||||||
|
/// exact input is preserved without normalization, case conversion or URL
|
||||||
|
/// encoding. Ref, validators and `scope_ref` are not inputs and cannot leak into
|
||||||
|
/// the label. This does not validate membership, freshness or authorization.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use wip_protocol::InterfaceReference;
|
||||||
|
/// use wip_tui::reference::format_interface_reference;
|
||||||
|
/// let reference = InterfaceReference {
|
||||||
|
/// scope: "/github::archive".into(), name: "issue#read".into(),
|
||||||
|
/// };
|
||||||
|
/// assert_eq!(format_interface_reference(&reference)?,
|
||||||
|
/// "\"/github::archive\"::\"issue#read\"");
|
||||||
|
/// # Ok::<(), wip_protocol::ValidationError>(())
|
||||||
|
/// ```
|
||||||
|
pub fn format_interface_reference(
|
||||||
|
reference: &InterfaceReference,
|
||||||
|
) -> Result<String, ValidationError> {
|
||||||
|
reference.validate()?;
|
||||||
|
Ok(format!(
|
||||||
|
"{}::{}",
|
||||||
|
component(&reference.scope, true),
|
||||||
|
component(&reference.name, false)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats a complete `scope::interface#operation` display designation.
|
||||||
|
///
|
||||||
|
/// Operation remains a separate exact Unicode name, not an input expression.
|
||||||
|
/// Delimiters inside it are quoted rather than split. This checks reference
|
||||||
|
/// shape, not operation existence, and adds no operation-name restrictions to
|
||||||
|
/// the Protocol. The output is plain text, without ANSI or Markdown; the UI
|
||||||
|
/// remains responsible for bounded screen layout, not identity reconstruction.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use wip_protocol::InterfaceReference;
|
||||||
|
/// use wip_tui::reference::format_qualified_operation;
|
||||||
|
/// let reference = InterfaceReference { scope: "/".into(), name: "issue.read".into() };
|
||||||
|
/// assert_eq!(format_qualified_operation(&reference, "read#raw")?,
|
||||||
|
/// "/::issue.read#\"read#raw\"");
|
||||||
|
/// # Ok::<(), wip_protocol::ValidationError>(())
|
||||||
|
/// ```
|
||||||
|
pub fn format_qualified_operation(
|
||||||
|
reference: &InterfaceReference,
|
||||||
|
operation: &str,
|
||||||
|
) -> Result<String, ValidationError> {
|
||||||
|
let mut output = format_interface_reference(reference)?;
|
||||||
|
output.push('#');
|
||||||
|
output.push_str(&component(operation, false));
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
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'/'))
|
||||||
|
{
|
||||||
|
return value.to_owned();
|
||||||
|
}
|
||||||
|
let mut output = String::from("\"");
|
||||||
|
for ch in value.chars() {
|
||||||
|
match ch {
|
||||||
|
'\\' => 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}' => {
|
||||||
|
// String's fmt::Write implementation is infallible.
|
||||||
|
write!(output, "\\u{:04x}", ch as u32).expect("String write");
|
||||||
|
}
|
||||||
|
_ => output.push(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.push('"');
|
||||||
|
output
|
||||||
|
}
|
||||||
@@ -11,6 +11,14 @@ use crate::editor::type_name;
|
|||||||
|
|
||||||
const MAX_RENDER_TEXT: usize = 512;
|
const MAX_RENDER_TEXT: usize = 512;
|
||||||
|
|
||||||
|
fn interface_label(reference: &wip_protocol::InterfaceReference) -> String {
|
||||||
|
format!(
|
||||||
|
"scope={}, name={}",
|
||||||
|
sanitize(&reference.scope),
|
||||||
|
sanitize(&reference.name)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Draws the complete application without mutating it.
|
/// Draws the complete application without mutating it.
|
||||||
pub fn draw(frame: &mut Frame<'_>, app: &App) {
|
pub fn draw(frame: &mut Frame<'_>, app: &App) {
|
||||||
let area = frame.area();
|
let area = frame.area();
|
||||||
@@ -162,7 +170,7 @@ fn render_selected(frame: &mut Frame<'_>, area: Rect, app: &App) {
|
|||||||
object
|
object
|
||||||
.interfaces
|
.interfaces
|
||||||
.iter()
|
.iter()
|
||||||
.map(|value| sanitize(value))
|
.map(interface_label)
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ")
|
.join(", ")
|
||||||
}
|
}
|
||||||
@@ -224,7 +232,7 @@ fn operation_line(app: &App, row: &OperationRow) -> (Line<'static>, Style) {
|
|||||||
.find(|operation| operation.name == *name)
|
.find(|operation| operation.name == *name)
|
||||||
});
|
});
|
||||||
let signature = declaration.map_or_else(
|
let signature = declaration.map_or_else(
|
||||||
|| format!(" ({}, {})", row.interface, name),
|
|| format!(" ({}, {})", interface_label(&row.interface), name),
|
||||||
|operation| {
|
|operation| {
|
||||||
let parameters = operation
|
let parameters = operation
|
||||||
.parameters
|
.parameters
|
||||||
@@ -246,7 +254,7 @@ fn operation_line(app: &App, row: &OperationRow) -> (Line<'static>, Style) {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
format!(
|
format!(
|
||||||
" ({}, {}) ({parameters}) -> {}{}",
|
" ({}, {}) ({parameters}) -> {}{}",
|
||||||
row.interface,
|
interface_label(&row.interface),
|
||||||
name,
|
name,
|
||||||
type_name(&operation.returns.r#type),
|
type_name(&operation.returns.r#type),
|
||||||
documentation
|
documentation
|
||||||
@@ -266,7 +274,7 @@ fn operation_line(app: &App, row: &OperationRow) -> (Line<'static>, Style) {
|
|||||||
(
|
(
|
||||||
Line::from(format!(
|
Line::from(format!(
|
||||||
"Interface {} [{}]{}",
|
"Interface {} [{}]{}",
|
||||||
sanitize(&row.interface),
|
interface_label(&row.interface),
|
||||||
state,
|
state,
|
||||||
documentation
|
documentation
|
||||||
)),
|
)),
|
||||||
@@ -355,7 +363,7 @@ fn render_modal(frame: &mut Frame<'_>, app: &App) {
|
|||||||
let mut lines = vec![Line::from(format!(
|
let mut lines = vec![Line::from(format!(
|
||||||
"Target: {} Operation: ({}, {})",
|
"Target: {} Operation: ({}, {})",
|
||||||
sanitize(&app.selected_path),
|
sanitize(&app.selected_path),
|
||||||
sanitize(&editor.interface),
|
interface_label(&editor.interface),
|
||||||
sanitize(&editor.operation.name)
|
sanitize(&editor.operation.name)
|
||||||
))];
|
))];
|
||||||
if editor.fields.is_empty() {
|
if editor.fields.is_empty() {
|
||||||
@@ -405,7 +413,7 @@ fn render_modal(frame: &mut Frame<'_>, app: &App) {
|
|||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
Line::from(format!("Entry: {}", sanitize(&app.selected_path))),
|
Line::from(format!("Entry: {}", sanitize(&app.selected_path))),
|
||||||
Line::from(format!("Interface: {}", sanitize(&editor.interface))),
|
Line::from(format!("Interface: {}", interface_label(&editor.interface))),
|
||||||
Line::from(format!("Operation: {}", sanitize(&editor.operation.name))),
|
Line::from(format!("Operation: {}", sanitize(&editor.operation.name))),
|
||||||
Line::from("Input values:"),
|
Line::from("Input values:"),
|
||||||
];
|
];
|
||||||
@@ -579,6 +587,12 @@ fn bounded_value_at(value: &wip_protocol::Value, depth: usize) -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||||
|
wip_protocol::InterfaceReference {
|
||||||
|
scope: "/".into(),
|
||||||
|
name: name.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use ratatui::backend::TestBackend;
|
use ratatui::backend::TestBackend;
|
||||||
|
|
||||||
@@ -664,7 +678,7 @@ mod tests {
|
|||||||
object: Some(Object {
|
object: Some(Object {
|
||||||
name: String::new(),
|
name: String::new(),
|
||||||
description: Some("public root description".into()),
|
description: Some("public root description".into()),
|
||||||
interfaces: vec!["demo-interface".into()],
|
interfaces: vec![root_reference("demo-interface")],
|
||||||
r#ref: None,
|
r#ref: None,
|
||||||
validator: Some(b"must-not-render".to_vec()),
|
validator: Some(b"must-not-render".to_vec()),
|
||||||
}),
|
}),
|
||||||
@@ -691,7 +705,8 @@ mod tests {
|
|||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
app.apply_interface(InterfaceObservation {
|
app.apply_interface(InterfaceObservation {
|
||||||
reference: "demo-interface".into(),
|
scope_ref: None,
|
||||||
|
reference: root_reference("demo-interface"),
|
||||||
descriptor: Some(descriptor.clone()),
|
descriptor: Some(descriptor.clone()),
|
||||||
validator: Some(b"must-not-render".to_vec()),
|
validator: Some(b"must-not-render".to_vec()),
|
||||||
state: ObservationState::Fresh,
|
state: ObservationState::Fresh,
|
||||||
@@ -711,7 +726,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
let snapshot = rendered(&app, 140, 40);
|
let snapshot = rendered(&app, 140, 40);
|
||||||
assert!(snapshot.contains("public root description"));
|
assert!(snapshot.contains("public root description"));
|
||||||
assert!(snapshot.contains("(demo-interface, run)"));
|
assert!(snapshot.contains("(scope=/, name=demo-interface, run)"));
|
||||||
assert!(snapshot.contains("SUCCESS"));
|
assert!(snapshot.contains("SUCCESS"));
|
||||||
assert!(snapshot.contains("PROTOCOL ERROR"));
|
assert!(snapshot.contains("PROTOCOL ERROR"));
|
||||||
assert!(snapshot.contains("TRANSPORT FAILURE"));
|
assert!(snapshot.contains("TRANSPORT FAILURE"));
|
||||||
@@ -719,7 +734,7 @@ mod tests {
|
|||||||
assert!(!snapshot.contains("must-not-render"));
|
assert!(!snapshot.contains("must-not-render"));
|
||||||
|
|
||||||
let mut editor = ArgumentEditor::new(
|
let mut editor = ArgumentEditor::new(
|
||||||
"demo-interface",
|
root_reference("demo-interface"),
|
||||||
descriptor.clone(),
|
descriptor.clone(),
|
||||||
descriptor.operations[0].clone(),
|
descriptor.operations[0].clone(),
|
||||||
);
|
);
|
||||||
@@ -774,7 +789,7 @@ mod tests {
|
|||||||
object: Some(Object {
|
object: Some(Object {
|
||||||
name: String::new(),
|
name: String::new(),
|
||||||
description: None,
|
description: None,
|
||||||
interfaces: vec!["many".into()],
|
interfaces: vec![root_reference("many")],
|
||||||
r#ref: None,
|
r#ref: None,
|
||||||
validator: None,
|
validator: None,
|
||||||
}),
|
}),
|
||||||
@@ -793,7 +808,8 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
app.apply_interface(InterfaceObservation {
|
app.apply_interface(InterfaceObservation {
|
||||||
reference: "many".into(),
|
scope_ref: None,
|
||||||
|
reference: root_reference("many"),
|
||||||
descriptor: Some(InterfaceDescriptor {
|
descriptor: Some(InterfaceDescriptor {
|
||||||
format: INTERFACE_FORMAT_V1.into(),
|
format: INTERFACE_FORMAT_V1.into(),
|
||||||
documentation: None,
|
documentation: None,
|
||||||
@@ -806,7 +822,7 @@ mod tests {
|
|||||||
app.focus = Focus::Operations;
|
app.focus = Focus::Operations;
|
||||||
app.operation_cursor = app.operation_rows().len() - 1;
|
app.operation_cursor = app.operation_rows().len() - 1;
|
||||||
let snapshot = rendered(&app, 100, 30);
|
let snapshot = rendered(&app, 100, 30);
|
||||||
assert!(snapshot.contains("(many, operation-19)"));
|
assert!(snapshot.contains("(scope=/, name=many, operation-19)"));
|
||||||
|
|
||||||
for index in 0..20 {
|
for index in 0..20 {
|
||||||
app.push_history(HistoryItem {
|
app.push_history(HistoryItem {
|
||||||
@@ -828,6 +844,19 @@ mod tests {
|
|||||||
assert!(snapshot.contains("too small"));
|
assert!(snapshot.contains("too small"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interface_labels_show_scope_and_name_without_parsing() {
|
||||||
|
let reference = wip_protocol::InterfaceReference {
|
||||||
|
scope: "/nested".into(),
|
||||||
|
name: "#::操作".into(),
|
||||||
|
};
|
||||||
|
assert_eq!(interface_label(&reference), "scope=/nested, name=#::操作");
|
||||||
|
assert_ne!(
|
||||||
|
interface_label(&reference),
|
||||||
|
interface_label(&root_reference(&reference.name))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn control_characters_and_large_bytes_are_bounded() {
|
fn control_characters_and_large_bytes_are_bounded() {
|
||||||
assert_eq!(sanitize("safe\u{1b}[31m"), "safe�[31m");
|
assert_eq!(sanitize("safe\u{1b}[31m"), "safe�[31m");
|
||||||
|
|||||||
@@ -303,7 +303,10 @@ impl<T: Transport> Runtime<T> {
|
|||||||
Err(error) => self.local_error("subtree refresh", error),
|
Err(error) => self.local_error("subtree refresh", error),
|
||||||
},
|
},
|
||||||
Action::FetchInterface(reference) => {
|
Action::FetchInterface(reference) => {
|
||||||
match self.client.prepare_interface(&self.session, &reference) {
|
match self
|
||||||
|
.client
|
||||||
|
.prepare_interface(&self.session, reference.clone())
|
||||||
|
{
|
||||||
Ok(request) => {
|
Ok(request) => {
|
||||||
self.dispatch(request, InFlightKind::Interface(reference), false)
|
self.dispatch(request, InFlightKind::Interface(reference), false)
|
||||||
}
|
}
|
||||||
@@ -367,7 +370,10 @@ impl<T: Transport> Runtime<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for reference in references {
|
for reference in references {
|
||||||
match self.client.ensure_interface(&self.session, &reference) {
|
match self
|
||||||
|
.client
|
||||||
|
.ensure_interface(&self.session, reference.clone())
|
||||||
|
{
|
||||||
Ok(Some(request)) => {
|
Ok(Some(request)) => {
|
||||||
self.dispatch(request, InFlightKind::Interface(reference), false);
|
self.dispatch(request, InFlightKind::Interface(reference), false);
|
||||||
}
|
}
|
||||||
@@ -599,8 +605,9 @@ impl<T: Transport> Runtime<T> {
|
|||||||
|
|
||||||
fn push_call_history(&mut self, record: &wip_client::CallRecord) {
|
fn push_call_history(&mut self, record: &wip_client::CallRecord) {
|
||||||
let key = format!(
|
let key = format!(
|
||||||
"({}, {}) on {}",
|
"(scope={}, name={}, {}) on {}",
|
||||||
record.context.interface().reference,
|
sanitize(&record.context.interface().reference.scope),
|
||||||
|
sanitize(&record.context.interface().reference.name),
|
||||||
record.context.operation().name,
|
record.context.operation().name,
|
||||||
record.context.object().path
|
record.context.object().path
|
||||||
);
|
);
|
||||||
@@ -683,7 +690,11 @@ impl<T: Transport> Runtime<T> {
|
|||||||
let title = match kind {
|
let title = match kind {
|
||||||
Some(InFlightKind::Object(path)) => format!("observe {path}"),
|
Some(InFlightKind::Object(path)) => format!("observe {path}"),
|
||||||
Some(InFlightKind::Tree(path)) => format!("tree {path}"),
|
Some(InFlightKind::Tree(path)) => format!("tree {path}"),
|
||||||
Some(InFlightKind::Interface(reference)) => format!("interface {reference}"),
|
Some(InFlightKind::Interface(reference)) => format!(
|
||||||
|
"interface scope={}, name={}",
|
||||||
|
sanitize(&reference.scope),
|
||||||
|
sanitize(&reference.name)
|
||||||
|
),
|
||||||
Some(InFlightKind::Operation { operation, .. }) => operation.clone(),
|
Some(InFlightKind::Operation { operation, .. }) => operation.clone(),
|
||||||
None => "request".into(),
|
None => "request".into(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
use wip_protocol::{InterfaceReference, InterfaceTarget};
|
||||||
|
use wip_tui::reference::{format_interface_reference, format_qualified_operation};
|
||||||
|
|
||||||
|
fn reference(scope: &str, name: &str) -> InterfaceReference {
|
||||||
|
InterfaceReference {
|
||||||
|
scope: scope.into(),
|
||||||
|
name: name.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn specification_reference_examples() {
|
||||||
|
for (scope, name, operation, expected) in [
|
||||||
|
("/github", "issue.read", "read", "/github::issue.read#read"),
|
||||||
|
("/", "issue.read", "read", "/::issue.read#read"),
|
||||||
|
(
|
||||||
|
"/github::archive",
|
||||||
|
"issue.read",
|
||||||
|
"read",
|
||||||
|
"\"/github::archive\"::issue.read#read",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/github",
|
||||||
|
"issue#read",
|
||||||
|
"read",
|
||||||
|
"/github::\"issue#read\"#read",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/github",
|
||||||
|
"issue.read",
|
||||||
|
"read#raw",
|
||||||
|
"/github::issue.read#\"read#raw\"",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/開発",
|
||||||
|
"課題.read",
|
||||||
|
"read",
|
||||||
|
"\"/開発\"::\"課題.read\"#read",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let input = reference(scope, name);
|
||||||
|
input.validate().unwrap();
|
||||||
|
let before = input.clone();
|
||||||
|
assert_eq!(
|
||||||
|
format_qualified_operation(&input, operation).unwrap(),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
assert_eq!(input, before);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn root_self_and_same_name_different_scope_stay_distinct() {
|
||||||
|
let root = reference("/", "read");
|
||||||
|
let parent = reference("/a", "read");
|
||||||
|
let self_scope = reference("/a/b", "read");
|
||||||
|
let labels: Vec<_> = [&root, &parent, &self_scope]
|
||||||
|
.into_iter()
|
||||||
|
.map(|input| {
|
||||||
|
input.validate_for_path("/a/b").unwrap();
|
||||||
|
format_interface_reference(input).unwrap()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(labels, ["/::read", "/a::read", "/a/b::read"]);
|
||||||
|
assert_eq!(
|
||||||
|
format_qualified_operation(&self_scope, "read").unwrap(),
|
||||||
|
"/a/b::read#read"
|
||||||
|
);
|
||||||
|
assert_ne!(root, self_scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn components_not_declaration_identifiers_or_signatures() {
|
||||||
|
// Reference components follow their own ASCII rules, not identifier/keyword
|
||||||
|
// backtick quoting or the outer JSON string quoting used by full signatures.
|
||||||
|
let input = reference("/a-b/c_d.1", "1-name.entry");
|
||||||
|
assert_eq!(
|
||||||
|
format_interface_reference(&input).unwrap(),
|
||||||
|
"/a-b/c_d.1::1-name.entry"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
format_qualified_operation(&input, "operation").unwrap(),
|
||||||
|
"/a-b/c_d.1::1-name.entry#operation"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
format_qualified_operation(&input, "a/b").unwrap(),
|
||||||
|
"/a-b/c_d.1::1-name.entry#\"a/b\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
format_qualified_operation(&input, "").unwrap(),
|
||||||
|
"/a-b/c_d.1::1-name.entry#\"\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
format_interface_reference(&reference("/", "name`tick")).unwrap(),
|
||||||
|
"/::\"name`tick\""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delimiters_quote_backslash_whitespace_and_newline_are_component_data() {
|
||||||
|
let input = reference("/a::b#c\"\\\n", "x::y#z\"\\\n");
|
||||||
|
let before = input.clone();
|
||||||
|
assert_eq!(
|
||||||
|
format_interface_reference(&input).unwrap(),
|
||||||
|
"\"/a::b#c\\\"\\\\\\n\"::\"x::y#z\\\"\\\\\\n\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
format_qualified_operation(&input, "read::raw# \"\\\n\t").unwrap(),
|
||||||
|
"\"/a::b#c\\\"\\\\\\n\"::\"x::y#z\\\"\\\\\\n\"#\"read::raw# \\\"\\\\\\n\\t\""
|
||||||
|
);
|
||||||
|
assert_eq!(input, before);
|
||||||
|
assert_eq!(
|
||||||
|
format_interface_reference(&reference("/space here", " name ")).unwrap(),
|
||||||
|
"\"/space here\"::\" name \""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_required_safety_escape_in_each_component() {
|
||||||
|
let controls = (0..=0x1f)
|
||||||
|
.chain(0x7f..=0x9f)
|
||||||
|
.chain([0x061c, 0x200e, 0x200f, 0x2028, 0x2029])
|
||||||
|
.chain(0x202a..=0x202e)
|
||||||
|
.chain(0x2066..=0x2069);
|
||||||
|
for code in controls {
|
||||||
|
let ch = char::from_u32(code).unwrap();
|
||||||
|
let escape = match code {
|
||||||
|
8 => "\\b".into(),
|
||||||
|
9 => "\\t".into(),
|
||||||
|
10 => "\\n".into(),
|
||||||
|
12 => "\\f".into(),
|
||||||
|
13 => "\\r".into(),
|
||||||
|
_ => format!("\\u{code:04x}"),
|
||||||
|
};
|
||||||
|
let input = reference(&format!("/a{ch}z"), &format!("n{ch}z"));
|
||||||
|
let operation = format!("o{ch}z");
|
||||||
|
let before = (input.clone(), operation.clone());
|
||||||
|
let label = format_qualified_operation(&input, &operation).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
label,
|
||||||
|
format!("\"/a{escape}z\"::\"n{escape}z\"#\"o{escape}z\""),
|
||||||
|
"U+{code:04X}"
|
||||||
|
);
|
||||||
|
assert!(!label.contains(ch), "raw U+{code:04X} leaked");
|
||||||
|
assert_eq!((input, operation), before);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unicode_remains_exact_no_normalization_case_folding_or_url_encoding() {
|
||||||
|
for name in ["é", "e\u{301}", "課題", "λ😀", "%23"] {
|
||||||
|
let input = reference(&format!("/{name}"), name);
|
||||||
|
assert_eq!(
|
||||||
|
format_qualified_operation(&input, name).unwrap(),
|
||||||
|
format!("\"/{name}\"::\"{name}\"#\"{name}\"")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_ne!(
|
||||||
|
format_interface_reference(&reference("/", "é")).unwrap(),
|
||||||
|
format_interface_reference(&reference("/", "e\u{301}")).unwrap()
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
format_interface_reference(&reference("/", "Read")).unwrap(),
|
||||||
|
format_interface_reference(&reference("/", "read")).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_invalid_shape_instead_of_inferring_scope() {
|
||||||
|
for scope in [
|
||||||
|
"",
|
||||||
|
"github",
|
||||||
|
"../github",
|
||||||
|
"/a/../b",
|
||||||
|
"/a/./b",
|
||||||
|
"/a//b",
|
||||||
|
"/a/",
|
||||||
|
] {
|
||||||
|
let input = reference(scope, "read");
|
||||||
|
assert_eq!(
|
||||||
|
format_interface_reference(&input).unwrap_err(),
|
||||||
|
input.validate().unwrap_err()
|
||||||
|
);
|
||||||
|
assert!(format_qualified_operation(&input, "read").is_err());
|
||||||
|
}
|
||||||
|
assert!(format_interface_reference(&reference("/", "")).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_metadata_and_call_identity_stay_separate_from_display() {
|
||||||
|
let mut target = InterfaceTarget {
|
||||||
|
reference: reference("/a", "read#raw"),
|
||||||
|
scope_ref: Some("secret-scope-ref".into()),
|
||||||
|
validator: Some(vec![27, 255]),
|
||||||
|
};
|
||||||
|
let before = target.clone();
|
||||||
|
let label = format_qualified_operation(&target.reference, "run").unwrap();
|
||||||
|
assert_eq!(label, "/a::\"read#raw\"#run");
|
||||||
|
assert_eq!(target, before);
|
||||||
|
target.scope_ref = None;
|
||||||
|
target.validator = None;
|
||||||
|
assert_eq!(
|
||||||
|
format_qualified_operation(&target.reference, "run").unwrap(),
|
||||||
|
label
|
||||||
|
);
|
||||||
|
// Selection/call keys are still exact protocol pairs; labels are disposable.
|
||||||
|
let mut selected = std::collections::BTreeMap::new();
|
||||||
|
selected.insert(target.reference.clone(), "run");
|
||||||
|
assert_eq!(selected.get(&before.reference), Some(&"run"));
|
||||||
|
assert!(!selected.contains_key(&reference("/", "read#raw")));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user