Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4544bc0d8b | ||
|
|
8ecc307fc9 | ||
|
|
bf5fa54f44 | ||
|
|
c6528c2887 | ||
|
|
b196047b57 | ||
|
|
ea49cb0586 | ||
|
|
da559de912 | ||
|
|
193ca89d93 | ||
|
|
dc9a7f0dbf | ||
|
|
3c4dbf3ad0 | ||
|
|
df006ee706 | ||
|
|
65c51006ad | ||
|
|
55c784c22d | ||
|
|
511e9fef9f | ||
|
|
06edf62d4e | ||
|
|
ea7b0f00ea | ||
|
|
2e4196a1ec |
@@ -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
+6
-6
@@ -1237,7 +1237,7 @@ checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||
name = "wip-client"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wip-https",
|
||||
"wip-http",
|
||||
"wip-protocol",
|
||||
]
|
||||
|
||||
@@ -1249,7 +1249,7 @@ dependencies = [
|
||||
"rustix 1.1.5",
|
||||
"tempfile",
|
||||
"wip-host",
|
||||
"wip-https",
|
||||
"wip-http",
|
||||
"wip-interface",
|
||||
"wip-protocol",
|
||||
]
|
||||
@@ -1264,7 +1264,7 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wip-https"
|
||||
name = "wip-http"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
@@ -1318,7 +1318,7 @@ dependencies = [
|
||||
"ureq",
|
||||
"wip-client",
|
||||
"wip-fs-server",
|
||||
"wip-https",
|
||||
"wip-http",
|
||||
"wip-protocol",
|
||||
]
|
||||
|
||||
@@ -1341,9 +1341,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "yoke-derive"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
|
||||
checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
+16
@@ -1,3 +1,19 @@
|
||||
[workspace]
|
||||
members = ["crates/*"]
|
||||
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.
|
||||
@@ -4,7 +4,7 @@ Rust building blocks for the Web Interface Protocol (WIP).
|
||||
|
||||
The repository is a resolver-3 virtual Cargo workspace whose packages all live
|
||||
under `crates/*`. It provides a transport-independent protocol model, the
|
||||
standard framework-independent WIP over HTTPS v1 binding, a stateful
|
||||
standard framework-independent WIP over HTTP v1 binding, a stateful
|
||||
presentation-independent client runtime, a Rust interface-authoring facade, a
|
||||
request-scoped host runtime, a bounded Linux filesystem projection server, an
|
||||
interactive terminal client, and a private public-API conformance consumer. The
|
||||
@@ -13,50 +13,79 @@ repository root is not itself a package.
|
||||
## Crate graph and responsibilities
|
||||
|
||||
```text
|
||||
wip-tui (interactive Worldspace explorer and operation editor)
|
||||
├── wip-client Known Space, request identity, validators, and calls
|
||||
├── wip-https framework-neutral HTTP request/response messages
|
||||
└── wip-protocol descriptors and logical values
|
||||
Published libraries
|
||||
|
||||
wip-fs-server (request-time filesystem projection and loopback listener)
|
||||
├── wip-https standard routes/codecs/envelopes/batching
|
||||
├── wip-host request-scoped publication and dispatch
|
||||
└── wip-interface macro-generated filesystem capabilities
|
||||
wip-client
|
||||
├── wip-http
|
||||
│ └── wip-protocol
|
||||
└── wip-protocol
|
||||
|
||||
wip-public-api-example (workspace-only executable, integration tests, doctest)
|
||||
├── wip-protocol logical declarations, values, calls, and validation
|
||||
├── wip-https standard HTTPS v1 routes, JSON codecs, envelopes, and batching
|
||||
wip-host
|
||||
├── wip-interface
|
||||
│ ├── wip-interface-macros
|
||||
│ └── wip-protocol
|
||||
├── wip-client stateful Known Space, lazy tree, observation, and call runtime
|
||||
│ ├── wip-https
|
||||
│ └── wip-protocol
|
||||
├── wip-interface application DTO codecs and interface authoring facade
|
||||
│ ├── wip-protocol
|
||||
│ └── wip-interface-macros proc-macro implementation detail
|
||||
└── wip-host descriptor registry, publication, and qualified dispatch
|
||||
├── wip-interface
|
||||
└── wip-protocol
|
||||
└── wip-protocol
|
||||
|
||||
Workspace-only applications and conformance consumers
|
||||
|
||||
wip-tui
|
||||
├── wip-client
|
||||
├── wip-http
|
||||
└── 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 |
|
||||
| --- | --- | --- |
|
||||
| `wip-protocol` | Transport-independent descriptors, object/call/result types, logical `Value`s, validation, and stable protocol error categories. | Application DTOs, implementations, persistence, or wire encoding. |
|
||||
| `wip-https` | Standard HTTPS v1 endpoint/route derivation, UTF-8 JSON codecs, direct success bodies, error/status classification, and retrieval batches. | HTTP I/O, TLS termination, authentication schemes, server frameworks, filesystem projection, or client presentation/cache policy. |
|
||||
| `wip-client` | Endpoint/security-context-isolated Known Space, normalized object and interface observations, lazy entry trees, immutable operation call contexts, stale transitions, and bounded histories. | HTTP I/O, credentials, persistent cache, automatic operation retries, or presentation widgets/events. |
|
||||
| `wip-tui` | Keyboard-only interactive Worldspace tree, selected Entry and qualified operation panes, descriptor-driven argument editing, bounded result/diagnostic rendering, terminal lifecycle, and HTTPS I/O workers over `wip-client`. | Independent cache/validator semantics, automatic operation retries, Host internals, backend paths, or persistent state. |
|
||||
| `wip-http` | Standard WIP over HTTP v1 endpoint/route derivation, UTF-8 JSON codecs, direct success bodies, and error/status classification. | HTTP I/O, URL scheme or TLS policy, authentication schemes, server frameworks, filesystem projection, or client presentation/cache policy. |
|
||||
| `wip-client` | Endpoint/security-context-isolated Known Space, normalized object and interface observations, intent-driven object and descriptor coverage with bounded request deduplication, immutable operation call contexts, stale transitions, and bounded histories. | HTTP I/O, credentials, persistent cache, automatic operation retries, or presentation widgets/events. |
|
||||
| `wip-tui` | Keyboard-only interactive Worldspace tree, selected Entry and qualified operation panes, descriptor-driven argument editing, bounded result/diagnostic rendering, terminal lifecycle, and HTTP I/O workers over `wip-client`. | Independent cache/validator semantics, automatic operation retries, Host internals, backend paths, or persistent state. |
|
||||
| `wip-interface` | The public `WipType`/`interface` authoring API, Rust-to-protocol codecs, generated descriptors, and typed implementation dispatch. | Object publication, backend lifetime, or transport policy. |
|
||||
| `wip-interface-macros` | Procedural-macro expansion used by `wip-interface`. It is not an application dependency. | A supported direct user API. |
|
||||
| `wip-host` | Opaque interface registration, explicit object membership, validator checks, interface-qualified dispatch against one host-owned instance, and descriptor-guided argument decoding inside that same resolution boundary. | Backend atomicity, global object lookup, or network serving. |
|
||||
| `wip-fs-server` | Linux directory-handle-confined request-time projection, bounded BFS trees and file operations, standard `wip-https` dispatch/batch handling, and a loopback HTTP binary for TLS-proxy or explicit development use. | A new wire profile, implicit symlink following, non-loopback cleartext, authentication policy, 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. |
|
||||
|
||||
## 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
|
||||
transport; application DTOs opt into that model with `WipType`; a host-private
|
||||
implementation owns credentials, handles, and backend state; and `wip-https`
|
||||
implementation owns credentials, handles, and backend state; and `wip-http`
|
||||
maps standard routes/envelopes to `CallOperationRequest` and
|
||||
`CallOperationResponse` without placing transport-specific types in descriptors
|
||||
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
|
||||
|
||||
[`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]`;
|
||||
3. a private host struct implementing both traits;
|
||||
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;
|
||||
6. calls qualified by `target.path`, object validator, interface reference and
|
||||
validator, operation, and schema-neutral `Value` arguments;
|
||||
@@ -263,8 +292,10 @@ from Worldspace `/`, lazily expands materialized tree nodes, displays current
|
||||
object/interface/operation metadata, validates all canonical descriptor types in
|
||||
a field editor, and requires a separate confirmation action before every call.
|
||||
It uses the stateful `wip-client` runtime for request correlation, observed
|
||||
validators, stale transitions, and unknown outcomes. HTTPS is the default;
|
||||
loopback plain HTTP requires `--allow-http`. Full startup, TLS, key binding,
|
||||
validators, stale transitions, and unknown outcomes. One canonical `http` or
|
||||
`https` endpoint is used unchanged for request construction, transport, and
|
||||
cache partitioning. Scheme, TLS, certificates, authentication, and proxy policy
|
||||
belong to the HTTP implementation and deployment. Full startup, key binding,
|
||||
layout, validator, and filesystem-operation examples are in the
|
||||
[package README](crates/wip-tui/README.md).
|
||||
|
||||
@@ -274,10 +305,12 @@ layout, validator, and filesystem-operation examples are in the
|
||||
binary and its full security model, configuration, curl walkthrough, and
|
||||
resource/failure semantics. It resolves every object from current filesystem
|
||||
state, uses pinned directory-relative handles without following symlinks, omits
|
||||
non-UTF-8 backend names, performs bounded breadth-first tree discovery, and
|
||||
publishes validator-protected replacement only in explicit read-write mode.
|
||||
The built-in cleartext listener is loopback-only: production HTTPS is explicitly
|
||||
delegated to a reverse proxy, while local plain HTTP requires an opt-in flag.
|
||||
non-UTF-8 backend names, returns complete recursive observations through the
|
||||
requested depth or a resource-limit failure, and publishes validator-protected
|
||||
replacement only in explicit read-write mode.
|
||||
The built-in HTTP listener is loopback-only. Externally visible URL schemes,
|
||||
TLS termination, certificates, authentication, and proxy routing are deployment
|
||||
concerns rather than separate WIP profiles.
|
||||
|
||||
## Validation
|
||||
|
||||
|
||||
@@ -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]
|
||||
name = "wip-client"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
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]
|
||||
wip-https = { path = "../wip-https" }
|
||||
wip-protocol = { path = "../wip-protocol" }
|
||||
wip-http.workspace = true
|
||||
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.
|
||||
+299
-255
@@ -1,8 +1,9 @@
|
||||
//! Presentation-independent stateful runtime for WIP clients.
|
||||
//!
|
||||
//! [`Client`] separates observations by canonical HTTPS endpoint and caller-
|
||||
//! supplied security context. It creates requests with the standard
|
||||
//! [`wip_https`] adapter, correlates responses by [`RequestId`], and retains
|
||||
//! [`Client`] separates observations by canonical WIP over HTTP endpoint and caller-
|
||||
//! supplied security context. It reconciles requested observation coverage without
|
||||
//! duplicating fresh or loading work, creates requests with the standard
|
||||
//! [`wip_http`] adapter, correlates responses by [`RequestId`], and retains
|
||||
//! bounded object, tree, interface, diagnostic, and operation state. The crate
|
||||
//! contains no terminal, widget, or UI event types.
|
||||
|
||||
@@ -12,17 +13,16 @@ use std::collections::{BTreeMap, VecDeque};
|
||||
use std::error::Error;
|
||||
use std::fmt::{self, Display, Formatter};
|
||||
|
||||
use wip_https::http::{Request, Response};
|
||||
use wip_https::{
|
||||
use wip_http::http::{Request, Response};
|
||||
use wip_http::{
|
||||
ClientResponseError, DecodedResponse, Endpoint, Limits, decode_call_operation_response,
|
||||
decode_fetch_interface_response, decode_fetch_response, decode_fetch_tree_response,
|
||||
encode_call_operation_request, encode_fetch_interface_request, encode_fetch_request,
|
||||
encode_fetch_tree_request,
|
||||
decode_fetch_interface_response, decode_observe_response, encode_call_operation_request,
|
||||
encode_fetch_interface_request, encode_observe_request,
|
||||
};
|
||||
use wip_protocol::{
|
||||
CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, FetchRequest,
|
||||
FetchTreeRequest, FetchTreeResponse, InterfaceDescriptor, InterfaceTarget, Object,
|
||||
ObjectTreeNode, OperationDeclaration, ProtocolError, ProtocolErrorCode, Target, Value,
|
||||
CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, InterfaceDescriptor,
|
||||
InterfaceReference, InterfaceTarget, Object, ObjectObservation as ProtocolObjectObservation,
|
||||
ObserveRequest, OperationDeclaration, ProtocolError, ProtocolErrorCode, Target, Value,
|
||||
};
|
||||
|
||||
/// Monotonic identity assigned to one logical request.
|
||||
@@ -84,7 +84,7 @@ impl SessionId {
|
||||
pub struct ClientLimits {
|
||||
/// Maximum simultaneously retained endpoint/security-context sessions.
|
||||
pub sessions: usize,
|
||||
/// Maximum object, path, and lazy-tree observations retained by each session.
|
||||
/// Maximum object, path, and observed-children state retained by each session.
|
||||
pub objects_per_session: usize,
|
||||
/// Maximum interface observations retained by each session.
|
||||
pub interfaces_per_session: usize,
|
||||
@@ -217,8 +217,6 @@ pub struct TreeObservation {
|
||||
pub path: String,
|
||||
/// Materialized direct child paths in Host order.
|
||||
pub children: Vec<String>,
|
||||
/// Whether the latest expansion rooted here omitted indexable entries.
|
||||
pub truncated: bool,
|
||||
/// Request revision that most recently owned this tree subject.
|
||||
pub revision: RequestId,
|
||||
/// Current expansion lifecycle.
|
||||
@@ -228,12 +226,14 @@ pub struct TreeObservation {
|
||||
/// Cached interface descriptor observation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InterfaceObservation {
|
||||
/// Opaque interface reference.
|
||||
pub reference: String,
|
||||
/// Structured interface reference.
|
||||
pub reference: InterfaceReference,
|
||||
/// Last accepted descriptor, if one has been received.
|
||||
pub descriptor: Option<InterfaceDescriptor>,
|
||||
/// Optional interface validator. Absence makes the reference immutable.
|
||||
pub validator: Option<Vec<u8>>,
|
||||
/// Optional opaque identity of the interface scope Object.
|
||||
pub scope_ref: Option<String>,
|
||||
/// Current observation lifecycle.
|
||||
pub state: ObservationState,
|
||||
}
|
||||
@@ -366,7 +366,7 @@ pub struct Diagnostic {
|
||||
pub struct PreparedRequest {
|
||||
/// Identity required when completing or failing this request.
|
||||
pub id: RequestId,
|
||||
/// Standard WIP over HTTPS request.
|
||||
/// Standard WIP over HTTP request.
|
||||
pub request: Request<Vec<u8>>,
|
||||
}
|
||||
|
||||
@@ -375,8 +375,6 @@ pub struct PreparedRequest {
|
||||
pub enum Completion {
|
||||
/// An object observation was updated.
|
||||
Object(ObjectObservation),
|
||||
/// A lazy tree expansion was updated.
|
||||
Tree(TreeObservation),
|
||||
/// An interface observation was updated.
|
||||
Interface(InterfaceObservation),
|
||||
/// An operation reached a terminal outcome.
|
||||
@@ -408,7 +406,7 @@ struct Session {
|
||||
objects: BTreeMap<u64, ObjectRecord>,
|
||||
refs: BTreeMap<String, u64>,
|
||||
trees: BTreeMap<String, TreeObservation>,
|
||||
interfaces: BTreeMap<String, InterfaceRecord>,
|
||||
interfaces: BTreeMap<InterfaceReference, InterfaceRecord>,
|
||||
calls: VecDeque<CallRecord>,
|
||||
diagnostics: VecDeque<Diagnostic>,
|
||||
next_object_key: u64,
|
||||
@@ -501,13 +499,9 @@ impl Session {
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Pending {
|
||||
Fetch {
|
||||
Observe {
|
||||
session: SessionId,
|
||||
request: FetchRequest,
|
||||
},
|
||||
Tree {
|
||||
session: SessionId,
|
||||
request: FetchTreeRequest,
|
||||
request: ObserveRequest,
|
||||
},
|
||||
Interface {
|
||||
session: SessionId,
|
||||
@@ -522,9 +516,7 @@ enum Pending {
|
||||
impl Pending {
|
||||
fn session(&self) -> &SessionId {
|
||||
match self {
|
||||
Self::Fetch { session, .. }
|
||||
| Self::Tree { session, .. }
|
||||
| Self::Interface { session, .. } => session,
|
||||
Self::Observe { session, .. } | Self::Interface { session, .. } => session,
|
||||
Self::Call { context, .. } => context.session(),
|
||||
}
|
||||
}
|
||||
@@ -606,7 +598,11 @@ impl Client {
|
||||
|
||||
/// Returns the current interface observation.
|
||||
#[must_use]
|
||||
pub fn interface(&self, session: &SessionId, reference: &str) -> Option<&InterfaceObservation> {
|
||||
pub fn interface(
|
||||
&self,
|
||||
session: &SessionId,
|
||||
reference: &InterfaceReference,
|
||||
) -> Option<&InterfaceObservation> {
|
||||
self.sessions
|
||||
.get(session)?
|
||||
.interfaces
|
||||
@@ -641,70 +637,86 @@ impl Client {
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts or refreshes an object observation.
|
||||
pub fn prepare_fetch(
|
||||
/// Ensures that an observation is available through `depth` without issuing
|
||||
/// duplicate work.
|
||||
///
|
||||
/// Returns `None` when the requested coverage is already fresh, is currently
|
||||
/// loading, is blocked by a retained failure or stale object state that requires
|
||||
/// an explicit refresh, or when the bounded in-flight capacity is currently
|
||||
/// full. Callers may invoke this method again after applying another completion.
|
||||
pub fn ensure_observed(
|
||||
&mut self,
|
||||
session: &SessionId,
|
||||
path: impl Into<String>,
|
||||
depth: u32,
|
||||
) -> Result<Option<PreparedRequest>, ClientError> {
|
||||
let path = path.into();
|
||||
wip_protocol::validate_path(&path).map_err(|error| {
|
||||
ClientError::new(ClientErrorKind::InvalidRequest, error.to_string())
|
||||
})?;
|
||||
let coverage = observation_coverage(self.session(session)?, &path, depth);
|
||||
if coverage != ObservationCoverage::Missing || self.pending.len() >= self.limits.in_flight {
|
||||
return Ok(None);
|
||||
}
|
||||
self.refresh_observed(session, path, depth).map(Some)
|
||||
}
|
||||
|
||||
/// Ensures that an object's direct children have been observed without
|
||||
/// duplicating fresh or loading work.
|
||||
pub fn ensure_children_observed(
|
||||
&mut self,
|
||||
session: &SessionId,
|
||||
path: impl Into<String>,
|
||||
) -> Result<Option<PreparedRequest>, ClientError> {
|
||||
self.ensure_observed(session, path, 1)
|
||||
}
|
||||
|
||||
/// Explicitly refreshes only one object while preserving previously observed
|
||||
/// children in Known Space.
|
||||
pub fn refresh_object(
|
||||
&mut self,
|
||||
session: &SessionId,
|
||||
path: impl Into<String>,
|
||||
) -> Result<PreparedRequest, ClientError> {
|
||||
self.refresh_observed(session, path, 0)
|
||||
}
|
||||
|
||||
/// Explicitly refreshes one object and its direct children.
|
||||
pub fn refresh_children(
|
||||
&mut self,
|
||||
session: &SessionId,
|
||||
path: impl Into<String>,
|
||||
) -> Result<PreparedRequest, ClientError> {
|
||||
self.ensure_capacity()?;
|
||||
let request = FetchRequest { path: path.into() };
|
||||
let endpoint = session_endpoint(session)?;
|
||||
let encoded =
|
||||
encode_fetch_request(&endpoint, &request, self.wire_limits).map_err(|error| {
|
||||
ClientError::new(ClientErrorKind::InvalidRequest, error.to_string())
|
||||
})?;
|
||||
let id = self.allocate_request_id();
|
||||
let maximum = self.limits.objects_per_session;
|
||||
let state = self.session_mut(session)?;
|
||||
ensure_entry_slot(state, &request.path, maximum)?;
|
||||
let entry = state
|
||||
.entries
|
||||
.entry(request.path.clone())
|
||||
.or_insert(EntryRecord {
|
||||
object_key: None,
|
||||
revision: id,
|
||||
state: ObservationState::Loading(id),
|
||||
});
|
||||
entry.revision = id;
|
||||
entry.state = ObservationState::Loading(id);
|
||||
self.pending.insert(
|
||||
id,
|
||||
Pending::Fetch {
|
||||
session: session.clone(),
|
||||
request,
|
||||
},
|
||||
);
|
||||
Ok(PreparedRequest {
|
||||
id,
|
||||
request: encoded,
|
||||
})
|
||||
self.refresh_observed(session, path, 1)
|
||||
}
|
||||
|
||||
/// Starts or refreshes a bounded lazy tree expansion.
|
||||
pub fn prepare_tree(
|
||||
/// Explicitly starts or refreshes an object observation through `depth`.
|
||||
///
|
||||
/// Unlike [`Client::ensure_observed`], this always creates a new request and
|
||||
/// supersedes older requests for the same root path.
|
||||
pub fn refresh_observed(
|
||||
&mut self,
|
||||
session: &SessionId,
|
||||
path: impl Into<String>,
|
||||
depth: u32,
|
||||
limit: u32,
|
||||
) -> Result<PreparedRequest, ClientError> {
|
||||
self.ensure_capacity()?;
|
||||
let request = FetchTreeRequest {
|
||||
let request = ObserveRequest {
|
||||
path: path.into(),
|
||||
depth,
|
||||
limit,
|
||||
};
|
||||
let endpoint = session_endpoint(session)?;
|
||||
let encoded =
|
||||
encode_fetch_tree_request(&endpoint, &request, self.wire_limits).map_err(|error| {
|
||||
encode_observe_request(&endpoint, &request, self.wire_limits).map_err(|error| {
|
||||
ClientError::new(ClientErrorKind::InvalidRequest, error.to_string())
|
||||
})?;
|
||||
let id = self.allocate_request_id();
|
||||
let maximum = self.limits.objects_per_session;
|
||||
let state = self.session_mut(session)?;
|
||||
ensure_entry_slot(state, &request.path, maximum)?;
|
||||
ensure_tree_slot(state, &request.path, maximum)?;
|
||||
if request.depth > 0 {
|
||||
ensure_tree_slot(state, &request.path, maximum)?;
|
||||
}
|
||||
let entry = state
|
||||
.entries
|
||||
.entry(request.path.clone())
|
||||
@@ -715,21 +727,26 @@ impl Client {
|
||||
});
|
||||
entry.revision = id;
|
||||
entry.state = ObservationState::Loading(id);
|
||||
let tree = state
|
||||
.trees
|
||||
.entry(request.path.clone())
|
||||
.or_insert(TreeObservation {
|
||||
path: request.path.clone(),
|
||||
children: Vec::new(),
|
||||
truncated: false,
|
||||
revision: id,
|
||||
state: ObservationState::Loading(id),
|
||||
});
|
||||
tree.revision = id;
|
||||
tree.state = ObservationState::Loading(id);
|
||||
if request.depth > 0 {
|
||||
let tree = state
|
||||
.trees
|
||||
.entry(request.path.clone())
|
||||
.or_insert(TreeObservation {
|
||||
path: request.path.clone(),
|
||||
children: Vec::new(),
|
||||
revision: id,
|
||||
state: ObservationState::Loading(id),
|
||||
});
|
||||
tree.revision = id;
|
||||
tree.state = ObservationState::Loading(id);
|
||||
} else if let Some(tree) = state.trees.get_mut(&request.path)
|
||||
&& matches!(tree.state, ObservationState::Loading(_))
|
||||
{
|
||||
tree.state = ObservationState::Stale;
|
||||
}
|
||||
self.pending.insert(
|
||||
id,
|
||||
Pending::Tree {
|
||||
Pending::Observe {
|
||||
session: session.clone(),
|
||||
request,
|
||||
},
|
||||
@@ -740,15 +757,41 @@ impl Client {
|
||||
})
|
||||
}
|
||||
|
||||
/// Starts or refreshes an interface descriptor observation.
|
||||
/// Ensures that an interface descriptor has been observed without issuing
|
||||
/// duplicate work or evicting another cached descriptor.
|
||||
///
|
||||
/// Returns `None` when the descriptor is already fresh, is currently loading,
|
||||
/// is blocked by retained stale or failure state that requires an explicit
|
||||
/// refresh, or when either the interface cache or bounded in-flight capacity is
|
||||
/// currently full. Callers may invoke this method again after applying another
|
||||
/// completion.
|
||||
pub fn ensure_interface(
|
||||
&mut self,
|
||||
session: &SessionId,
|
||||
reference: InterfaceReference,
|
||||
) -> Result<Option<PreparedRequest>, ClientError> {
|
||||
let state = self.session(session)?;
|
||||
if state.interfaces.contains_key(&reference)
|
||||
|| state.interfaces.len() >= self.limits.interfaces_per_session
|
||||
|| self.pending.len() >= self.limits.in_flight
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
self.prepare_interface(session, reference).map(Some)
|
||||
}
|
||||
|
||||
/// Explicitly starts or refreshes an interface descriptor observation.
|
||||
///
|
||||
/// Unlike [`Client::ensure_interface`], this always creates a new request and
|
||||
/// may evict an older non-loading descriptor when the interface cache is full.
|
||||
pub fn prepare_interface(
|
||||
&mut self,
|
||||
session: &SessionId,
|
||||
reference: impl Into<String>,
|
||||
reference: InterfaceReference,
|
||||
) -> Result<PreparedRequest, ClientError> {
|
||||
self.ensure_capacity()?;
|
||||
let request = FetchInterfaceRequest {
|
||||
interface: reference.into(),
|
||||
interface: reference,
|
||||
};
|
||||
let endpoint = session_endpoint(session)?;
|
||||
let encoded = encode_fetch_interface_request(&endpoint, &request, self.wire_limits)
|
||||
@@ -766,6 +809,7 @@ impl Client {
|
||||
.or_insert(InterfaceRecord {
|
||||
observation: InterfaceObservation {
|
||||
reference: request.interface.clone(),
|
||||
scope_ref: None,
|
||||
descriptor: None,
|
||||
validator: None,
|
||||
state: ObservationState::Loading(id),
|
||||
@@ -795,7 +839,7 @@ impl Client {
|
||||
&mut self,
|
||||
session: &SessionId,
|
||||
path: &str,
|
||||
interface: &str,
|
||||
interface: &InterfaceReference,
|
||||
operation: impl Into<String>,
|
||||
arguments: BTreeMap<String, Value>,
|
||||
) -> Result<PreparedRequest, ClientError> {
|
||||
@@ -862,7 +906,8 @@ impl Client {
|
||||
validator: object.validator.clone(),
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: interface.to_owned(),
|
||||
reference: interface.clone(),
|
||||
scope_ref: interface_observation.scope_ref.clone(),
|
||||
validator: interface_observation.validator.clone(),
|
||||
},
|
||||
operation,
|
||||
@@ -928,11 +973,8 @@ impl Client {
|
||||
) -> Result<Completion, ClientError> {
|
||||
let pending = self.pending.remove(&id).ok_or_else(unknown_request)?;
|
||||
match pending {
|
||||
Pending::Fetch { session, request } => {
|
||||
self.complete_fetch(id, &session, request, response)
|
||||
}
|
||||
Pending::Tree { session, request } => {
|
||||
self.complete_tree(id, &session, request, response)
|
||||
Pending::Observe { session, request } => {
|
||||
self.complete_observe(id, &session, request, response)
|
||||
}
|
||||
Pending::Interface { session, request } => {
|
||||
self.complete_interface(id, &session, request, response)
|
||||
@@ -994,12 +1036,9 @@ impl Client {
|
||||
other => {
|
||||
let session = other.session().clone();
|
||||
let is_current = match &other {
|
||||
Pending::Fetch { request, .. } => {
|
||||
Pending::Observe { request, .. } => {
|
||||
self.entry_request_is_current(&session, &request.path, id)
|
||||
}
|
||||
Pending::Tree { request, .. } => {
|
||||
self.tree_request_is_current(&session, &request.path, id)
|
||||
}
|
||||
Pending::Interface { request, .. } => {
|
||||
self.interface_request_is_current(&session, &request.interface, id)
|
||||
}
|
||||
@@ -1054,28 +1093,29 @@ impl Client {
|
||||
result
|
||||
}
|
||||
|
||||
fn complete_fetch(
|
||||
fn complete_observe(
|
||||
&mut self,
|
||||
id: RequestId,
|
||||
session: &SessionId,
|
||||
request: FetchRequest,
|
||||
request: ObserveRequest,
|
||||
response: Response<Vec<u8>>,
|
||||
) -> Result<Completion, ClientError> {
|
||||
if !self.entry_request_is_current(session, &request.path, id) {
|
||||
return self.reject_stale(session, id);
|
||||
}
|
||||
match decode_fetch_response(&request, &response, self.wire_limits) {
|
||||
Ok(DecodedResponse::Success(object)) => {
|
||||
match decode_observe_response(&request, &response, self.wire_limits) {
|
||||
Ok(DecodedResponse::Success(value)) => {
|
||||
let maximum = self.limits.objects_per_session;
|
||||
let mut candidate = self.session(session)?.clone();
|
||||
if let Err(error) =
|
||||
insert_object(&mut candidate, &request.path, object, maximum, id)
|
||||
apply_observation(&mut candidate, &request.path, value, maximum, id)
|
||||
{
|
||||
self.session_mut(session)?
|
||||
.entries
|
||||
.get_mut(&request.path)
|
||||
.expect("loading entry")
|
||||
.state = ObservationState::Error(error.clone());
|
||||
set_observe_subject_state(
|
||||
self.session_mut(session)?,
|
||||
&request,
|
||||
id,
|
||||
ObservationState::Error(error.clone()),
|
||||
);
|
||||
self.push_failure_diagnostic(session, id, &error)?;
|
||||
return Err(error);
|
||||
}
|
||||
@@ -1085,62 +1125,6 @@ impl Client {
|
||||
self.sessions.insert(session.clone(), candidate);
|
||||
Ok(Completion::Object(observation))
|
||||
}
|
||||
Ok(DecodedResponse::ProtocolFailure {
|
||||
error,
|
||||
status_mismatch,
|
||||
}) => {
|
||||
self.record_status_mismatch(session, id, status_mismatch)?;
|
||||
self.session_mut(session)?
|
||||
.entries
|
||||
.get_mut(&request.path)
|
||||
.expect("loading entry")
|
||||
.state = if error.code == ProtocolErrorCode::NotFound {
|
||||
ObservationState::Stale
|
||||
} else {
|
||||
ObservationState::Error(ClientError::new(
|
||||
ClientErrorKind::ProtocolFailure,
|
||||
error.to_string(),
|
||||
))
|
||||
};
|
||||
Ok(Completion::ProtocolFailure(error))
|
||||
}
|
||||
Err(error) => self.retrieval_decode_error(
|
||||
id,
|
||||
session,
|
||||
RetrievalSubject::Object(request.path),
|
||||
error,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn complete_tree(
|
||||
&mut self,
|
||||
id: RequestId,
|
||||
session: &SessionId,
|
||||
request: FetchTreeRequest,
|
||||
response: Response<Vec<u8>>,
|
||||
) -> Result<Completion, ClientError> {
|
||||
if !self.tree_request_is_current(session, &request.path, id) {
|
||||
return self.reject_stale(session, id);
|
||||
}
|
||||
match decode_fetch_tree_response(&request, &response, self.wire_limits) {
|
||||
Ok(DecodedResponse::Success(tree)) => {
|
||||
let maximum = self.limits.objects_per_session;
|
||||
let mut candidate = self.session(session)?.clone();
|
||||
if let Err(error) = apply_tree(&mut candidate, &request.path, tree, maximum, id) {
|
||||
set_tree_subject_state(
|
||||
self.session_mut(session)?,
|
||||
&request.path,
|
||||
id,
|
||||
ObservationState::Error(error.clone()),
|
||||
);
|
||||
self.push_failure_diagnostic(session, id, &error)?;
|
||||
return Err(error);
|
||||
}
|
||||
let observation = candidate.trees[&request.path].clone();
|
||||
self.sessions.insert(session.clone(), candidate);
|
||||
Ok(Completion::Tree(observation))
|
||||
}
|
||||
Ok(DecodedResponse::ProtocolFailure {
|
||||
error,
|
||||
status_mismatch,
|
||||
@@ -1154,20 +1138,17 @@ impl Client {
|
||||
error.to_string(),
|
||||
))
|
||||
};
|
||||
set_tree_subject_state(
|
||||
set_observe_subject_state(
|
||||
self.session_mut(session)?,
|
||||
&request.path,
|
||||
&request,
|
||||
id,
|
||||
observation_state,
|
||||
);
|
||||
Ok(Completion::ProtocolFailure(error))
|
||||
}
|
||||
Err(error) => self.retrieval_decode_error(
|
||||
id,
|
||||
session,
|
||||
RetrievalSubject::Tree(request.path),
|
||||
error,
|
||||
),
|
||||
Err(error) => {
|
||||
self.retrieval_decode_error(id, session, RetrievalSubject::Observe(request), error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1208,6 +1189,7 @@ impl Client {
|
||||
let touched = state.tick();
|
||||
let observation = InterfaceObservation {
|
||||
reference: value.interface,
|
||||
scope_ref: value.scope_ref,
|
||||
descriptor: Some(value.descriptor),
|
||||
validator: value.validator,
|
||||
state: ObservationState::Fresh,
|
||||
@@ -1261,11 +1243,14 @@ impl Client {
|
||||
let session_id = context.session.clone();
|
||||
let maximum = self.limits.history;
|
||||
let state = self.session_mut(&session_id)?;
|
||||
if let Some(object) = state.objects.get_mut(&context.object_key) {
|
||||
object.object.validator = value.validator.clone();
|
||||
object.revision = object.revision.max(id);
|
||||
if let Some(object) = state.objects.get_mut(&context.object_key)
|
||||
&& object.revision <= id
|
||||
{
|
||||
if let Some(validator) = value.validator.clone() {
|
||||
object.object.validator = Some(validator);
|
||||
}
|
||||
object.revision = id;
|
||||
}
|
||||
state.mark_object_stale(context.object_key, id);
|
||||
let record = CallRecord {
|
||||
request_id: id,
|
||||
context,
|
||||
@@ -1369,17 +1354,10 @@ impl Client {
|
||||
) -> Result<Completion, ClientError> {
|
||||
let error = classify_response_error(error);
|
||||
match subject {
|
||||
RetrievalSubject::Object(path) => {
|
||||
self.session_mut(session)?
|
||||
.entries
|
||||
.get_mut(&path)
|
||||
.expect("entry exists")
|
||||
.state = ObservationState::Error(error.clone());
|
||||
}
|
||||
RetrievalSubject::Tree(path) => {
|
||||
set_tree_subject_state(
|
||||
RetrievalSubject::Observe(request) => {
|
||||
set_observe_subject_state(
|
||||
self.session_mut(session)?,
|
||||
&path,
|
||||
&request,
|
||||
id,
|
||||
ObservationState::Error(error.clone()),
|
||||
);
|
||||
@@ -1404,17 +1382,10 @@ impl Client {
|
||||
error: ClientError,
|
||||
) -> Result<(), ClientError> {
|
||||
match pending {
|
||||
Pending::Fetch { session, request } => {
|
||||
self.session_mut(&session)?
|
||||
.entries
|
||||
.get_mut(&request.path)
|
||||
.expect("entry exists")
|
||||
.state = ObservationState::Error(error);
|
||||
}
|
||||
Pending::Tree { session, request } => {
|
||||
set_tree_subject_state(
|
||||
Pending::Observe { session, request } => {
|
||||
set_observe_subject_state(
|
||||
self.session_mut(&session)?,
|
||||
&request.path,
|
||||
&request,
|
||||
id,
|
||||
ObservationState::Error(error),
|
||||
);
|
||||
@@ -1436,7 +1407,7 @@ impl Client {
|
||||
&mut self,
|
||||
session: &SessionId,
|
||||
id: RequestId,
|
||||
mismatch: Option<wip_https::StatusMismatch>,
|
||||
mismatch: Option<wip_http::StatusMismatch>,
|
||||
) -> Result<(), ClientError> {
|
||||
if let Some(mismatch) = mismatch {
|
||||
let maximum = self.limits.history;
|
||||
@@ -1500,20 +1471,10 @@ impl Client {
|
||||
)
|
||||
}
|
||||
|
||||
fn tree_request_is_current(&self, session: &SessionId, path: &str, id: RequestId) -> bool {
|
||||
matches!(
|
||||
self.sessions
|
||||
.get(session)
|
||||
.and_then(|state| state.trees.get(path))
|
||||
.map(|tree| &tree.state),
|
||||
Some(ObservationState::Loading(current)) if *current == id
|
||||
)
|
||||
}
|
||||
|
||||
fn interface_request_is_current(
|
||||
&self,
|
||||
session: &SessionId,
|
||||
reference: &str,
|
||||
reference: &InterfaceReference,
|
||||
id: RequestId,
|
||||
) -> bool {
|
||||
matches!(
|
||||
@@ -1557,9 +1518,66 @@ impl Client {
|
||||
|
||||
#[derive(Debug)]
|
||||
enum RetrievalSubject {
|
||||
Object(String),
|
||||
Tree(String),
|
||||
Interface(String),
|
||||
Observe(ObserveRequest),
|
||||
Interface(InterfaceReference),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ObservationCoverage {
|
||||
Missing,
|
||||
Loading,
|
||||
Covered,
|
||||
Blocked,
|
||||
}
|
||||
|
||||
fn observation_coverage(state: &Session, path: &str, depth: u32) -> ObservationCoverage {
|
||||
let Some(entry) = state.entries.get(path) else {
|
||||
return ObservationCoverage::Missing;
|
||||
};
|
||||
match &entry.state {
|
||||
ObservationState::Loading(_) => return ObservationCoverage::Loading,
|
||||
ObservationState::Stale | ObservationState::Error(_) => {
|
||||
return ObservationCoverage::Blocked;
|
||||
}
|
||||
ObservationState::Fresh => {}
|
||||
}
|
||||
if entry
|
||||
.object_key
|
||||
.is_none_or(|key| !state.objects.contains_key(&key))
|
||||
{
|
||||
return ObservationCoverage::Missing;
|
||||
}
|
||||
if depth == 0 {
|
||||
return ObservationCoverage::Covered;
|
||||
}
|
||||
|
||||
let Some(tree) = state.trees.get(path) else {
|
||||
return ObservationCoverage::Missing;
|
||||
};
|
||||
match &tree.state {
|
||||
ObservationState::Loading(_) => return ObservationCoverage::Loading,
|
||||
ObservationState::Stale => return ObservationCoverage::Missing,
|
||||
ObservationState::Error(_) => return ObservationCoverage::Blocked,
|
||||
ObservationState::Fresh => {}
|
||||
}
|
||||
|
||||
let mut saw_loading = false;
|
||||
let mut saw_missing = false;
|
||||
for child in &tree.children {
|
||||
match observation_coverage(state, child, depth - 1) {
|
||||
ObservationCoverage::Blocked => return ObservationCoverage::Blocked,
|
||||
ObservationCoverage::Loading => saw_loading = true,
|
||||
ObservationCoverage::Missing => saw_missing = true,
|
||||
ObservationCoverage::Covered => {}
|
||||
}
|
||||
}
|
||||
if saw_loading {
|
||||
ObservationCoverage::Loading
|
||||
} else if saw_missing {
|
||||
ObservationCoverage::Missing
|
||||
} else {
|
||||
ObservationCoverage::Covered
|
||||
}
|
||||
}
|
||||
|
||||
fn session_endpoint(session: &SessionId) -> Result<Endpoint, ClientError> {
|
||||
@@ -1586,18 +1604,19 @@ fn classify_response_error(error: ClientResponseError) -> ClientError {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_tree_subject_state(
|
||||
fn set_observe_subject_state(
|
||||
state: &mut Session,
|
||||
path: &str,
|
||||
request: &ObserveRequest,
|
||||
revision: RequestId,
|
||||
observation_state: ObservationState,
|
||||
) {
|
||||
if let Some(tree) = state.trees.get_mut(path)
|
||||
if request.depth > 0
|
||||
&& let Some(tree) = state.trees.get_mut(&request.path)
|
||||
&& tree.revision == revision
|
||||
{
|
||||
tree.state = observation_state.clone();
|
||||
}
|
||||
if let Some(entry) = state.entries.get_mut(path)
|
||||
if let Some(entry) = state.entries.get_mut(&request.path)
|
||||
&& entry.revision == revision
|
||||
{
|
||||
entry.state = observation_state;
|
||||
@@ -1606,7 +1625,7 @@ fn set_tree_subject_state(
|
||||
|
||||
fn ensure_interface_slot(
|
||||
state: &mut Session,
|
||||
reference: &str,
|
||||
reference: &InterfaceReference,
|
||||
maximum: usize,
|
||||
) -> Result<(), ClientError> {
|
||||
if state.interfaces.contains_key(reference) || state.interfaces.len() < maximum {
|
||||
@@ -1913,9 +1932,10 @@ fn remove_object(state: &mut Session, key: u64) {
|
||||
}
|
||||
}
|
||||
|
||||
fn reserve_tree_metadata(
|
||||
fn reserve_observation_metadata(
|
||||
state: &mut Session,
|
||||
paths: &[String],
|
||||
observed_children_paths: &[String],
|
||||
maximum: usize,
|
||||
) -> Result<(), ClientError> {
|
||||
while state.entries.len()
|
||||
@@ -1952,7 +1972,7 @@ fn reserve_tree_metadata(
|
||||
remove_entry(state, &candidate);
|
||||
}
|
||||
while state.trees.len()
|
||||
+ paths
|
||||
+ observed_children_paths
|
||||
.iter()
|
||||
.filter(|path| !state.trees.contains_key(*path))
|
||||
.count()
|
||||
@@ -1962,7 +1982,8 @@ fn reserve_tree_metadata(
|
||||
.trees
|
||||
.iter()
|
||||
.find(|(path, tree)| {
|
||||
!paths.contains(path) && !matches!(tree.state, ObservationState::Loading(_))
|
||||
!observed_children_paths.contains(path)
|
||||
&& !matches!(tree.state, ObservationState::Loading(_))
|
||||
})
|
||||
.map(|(path, _)| path.clone())
|
||||
.ok_or_else(|| {
|
||||
@@ -1990,45 +2011,60 @@ fn mark_subtree_stale(state: &mut Session, root: &str, request_id: RequestId) {
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_tree(
|
||||
fn apply_observation(
|
||||
state: &mut Session,
|
||||
root_path: &str,
|
||||
response: FetchTreeResponse,
|
||||
response: ProtocolObjectObservation,
|
||||
maximum: usize,
|
||||
request_id: RequestId,
|
||||
) -> Result<(), ClientError> {
|
||||
let mut nodes = Vec::new();
|
||||
flatten_tree(root_path, &response.root, &mut nodes);
|
||||
flatten_observation(root_path, &response, &mut nodes);
|
||||
if nodes.len() > maximum {
|
||||
return Err(ClientError::new(
|
||||
ClientErrorKind::Capacity,
|
||||
"tree response exceeds the local observation bound",
|
||||
"observation response exceeds the local observation bound",
|
||||
));
|
||||
}
|
||||
let paths: Vec<String> = nodes.iter().map(|(path, _, _)| path.clone()).collect();
|
||||
reserve_tree_metadata(state, &paths, maximum)?;
|
||||
let observed_children_paths: Vec<String> = nodes
|
||||
.iter()
|
||||
.filter_map(|(path, _, children)| children.as_ref().map(|_| path.clone()))
|
||||
.collect();
|
||||
reserve_observation_metadata(state, &paths, &observed_children_paths, maximum)?;
|
||||
|
||||
if !response.truncated {
|
||||
let mut omitted = Vec::new();
|
||||
for (path, _, children) in &nodes {
|
||||
if let Some(previous) = state
|
||||
.trees
|
||||
.get(path)
|
||||
.filter(|previous| previous.revision <= request_id)
|
||||
{
|
||||
omitted.extend(
|
||||
previous
|
||||
.children
|
||||
.iter()
|
||||
.filter(|child| !children.contains(child))
|
||||
.cloned(),
|
||||
);
|
||||
}
|
||||
for (path, _, children) in &nodes {
|
||||
if children.is_none()
|
||||
&& let Some(tree) = state.trees.get_mut(path)
|
||||
&& tree.revision < request_id
|
||||
&& matches!(tree.state, ObservationState::Loading(_))
|
||||
{
|
||||
tree.state = ObservationState::Stale;
|
||||
}
|
||||
for path in omitted {
|
||||
mark_subtree_stale(state, &path, request_id);
|
||||
}
|
||||
|
||||
let mut omitted = Vec::new();
|
||||
for (path, _, children) in &nodes {
|
||||
let Some(children) = children else {
|
||||
continue;
|
||||
};
|
||||
if let Some(previous) = state
|
||||
.trees
|
||||
.get(path)
|
||||
.filter(|previous| previous.revision <= request_id)
|
||||
{
|
||||
omitted.extend(
|
||||
previous
|
||||
.children
|
||||
.iter()
|
||||
.filter(|child| !children.contains(child))
|
||||
.cloned(),
|
||||
);
|
||||
}
|
||||
}
|
||||
for path in omitted {
|
||||
mark_subtree_stale(state, &path, request_id);
|
||||
}
|
||||
|
||||
for (path, object, _) in &nodes {
|
||||
validate_object_consistency(state, object)?;
|
||||
@@ -2041,6 +2077,9 @@ fn apply_tree(
|
||||
}
|
||||
}
|
||||
for (path, _, children) in nodes {
|
||||
let Some(children) = children else {
|
||||
continue;
|
||||
};
|
||||
let newer_owner = state
|
||||
.trees
|
||||
.get(&path)
|
||||
@@ -2048,13 +2087,11 @@ fn apply_tree(
|
||||
if newer_owner {
|
||||
continue;
|
||||
}
|
||||
let is_root = path == root_path;
|
||||
state.trees.insert(
|
||||
path.clone(),
|
||||
TreeObservation {
|
||||
path,
|
||||
children,
|
||||
truncated: is_root && response.truncated,
|
||||
revision: request_id,
|
||||
state: ObservationState::Fresh,
|
||||
},
|
||||
@@ -2063,19 +2100,26 @@ fn apply_tree(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn flatten_tree(
|
||||
fn flatten_observation(
|
||||
path: &str,
|
||||
node: &ObjectTreeNode,
|
||||
output: &mut Vec<(String, Object, Vec<String>)>,
|
||||
observation: &ProtocolObjectObservation,
|
||||
output: &mut Vec<(String, Object, Option<Vec<String>>)>,
|
||||
) {
|
||||
let children: Vec<String> = node
|
||||
.children
|
||||
.iter()
|
||||
.map(|child| child_path(path, &child.object.name))
|
||||
.collect();
|
||||
output.push((path.to_owned(), node.object.clone(), children.clone()));
|
||||
for (child, child_path) in node.children.iter().zip(children) {
|
||||
flatten_tree(&child_path, child, output);
|
||||
let children = observation.children.as_ref().map(|children| {
|
||||
children
|
||||
.iter()
|
||||
.map(|child| child_path(path, &child.object.name))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
output.push((
|
||||
path.to_owned(),
|
||||
observation.object.clone(),
|
||||
children.clone(),
|
||||
));
|
||||
if let (Some(observations), Some(paths)) = (&observation.children, children) {
|
||||
for (child, child_path) in observations.iter().zip(paths) {
|
||||
flatten_observation(&child_path, child, output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||
wip_protocol::InterfaceReference {
|
||||
scope: "/".into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use wip_client::{
|
||||
CallOutcome, Client, ClientErrorKind, ClientLimits, Completion, DiagnosticKind,
|
||||
ObservationState, OutcomeUnknownReason, SecurityContext,
|
||||
};
|
||||
use wip_https::http::{Response, StatusCode};
|
||||
use wip_https::{
|
||||
Limits, encode_fetch_interface_response, encode_fetch_response, encode_protocol_error_response,
|
||||
use wip_http::http::{Response, StatusCode};
|
||||
use wip_http::{
|
||||
Limits, encode_fetch_interface_response, encode_observe_response,
|
||||
encode_protocol_error_response,
|
||||
};
|
||||
use wip_protocol::{
|
||||
FetchInterfaceRequest, FetchInterfaceResponse, FetchRequest, INTERFACE_FORMAT_V1,
|
||||
InterfaceDescriptor, Object, OperationDeclaration, ParameterDeclaration, ProtocolError,
|
||||
ProtocolErrorCode, ProtocolInteraction, ReturnDeclaration, TypeExpr, Value,
|
||||
FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1, InterfaceDescriptor,
|
||||
Object, ObjectObservation, ObserveRequest, OperationDeclaration, ParameterDeclaration,
|
||||
ProtocolError, ProtocolErrorCode, ProtocolInteraction, ReturnDeclaration, TypeExpr, Value,
|
||||
};
|
||||
|
||||
fn limits() -> Limits {
|
||||
Limits::new(64 * 1024, 64 * 1024, 64, 64).unwrap()
|
||||
Limits::new(64 * 1024, 64 * 1024, 64).unwrap()
|
||||
}
|
||||
|
||||
fn setup() -> (Client, wip_client::SessionId) {
|
||||
@@ -38,7 +46,7 @@ fn object() -> Object {
|
||||
Object {
|
||||
name: "item".into(),
|
||||
description: None,
|
||||
interfaces: vec!["math".into()],
|
||||
interfaces: vec![root_reference("math")],
|
||||
r#ref: None,
|
||||
validator: Some(b"object-v1".to_vec()),
|
||||
}
|
||||
@@ -71,12 +79,12 @@ fn malformed_json_unknown_fields_duplicates_and_invalid_success_are_invalid_resp
|
||||
let cases: &[&[u8]] = &[
|
||||
b"{not json",
|
||||
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":[]}"#,
|
||||
];
|
||||
for body in cases {
|
||||
let (mut client, session) = setup();
|
||||
let request = client.prepare_fetch(&session, "/item").unwrap();
|
||||
let request = client.refresh_observed(&session, "/item", 0).unwrap();
|
||||
let error = client
|
||||
.complete(request.id, response(StatusCode::OK, body))
|
||||
.unwrap_err();
|
||||
@@ -106,7 +114,7 @@ fn disallowed_code_and_status_success_contradictions_are_invalid_response() {
|
||||
];
|
||||
for (status, body) in cases {
|
||||
let (mut client, session) = setup();
|
||||
let request = client.prepare_fetch(&session, "/item").unwrap();
|
||||
let request = client.refresh_observed(&session, "/item", 0).unwrap();
|
||||
assert_eq!(
|
||||
client
|
||||
.complete(request.id, response(status, body))
|
||||
@@ -120,13 +128,13 @@ fn disallowed_code_and_status_success_contradictions_are_invalid_response() {
|
||||
#[test]
|
||||
fn valid_error_envelope_is_authoritative_and_status_mismatch_is_diagnostic() {
|
||||
let (mut client, session) = setup();
|
||||
let request = client.prepare_fetch(&session, "/item").unwrap();
|
||||
let request = client.refresh_observed(&session, "/item", 0).unwrap();
|
||||
let error = ProtocolError {
|
||||
code: ProtocolErrorCode::NotFound,
|
||||
message: "missing".into(),
|
||||
};
|
||||
let mut encoded =
|
||||
encode_protocol_error_response(ProtocolInteraction::Fetch, &error, limits()).unwrap();
|
||||
encode_protocol_error_response(ProtocolInteraction::Observe, &error, limits()).unwrap();
|
||||
*encoded.status_mut() = StatusCode::IM_A_TEAPOT;
|
||||
|
||||
assert_eq!(
|
||||
@@ -144,27 +152,51 @@ fn valid_error_envelope_is_authoritative_and_status_mismatch_is_diagnostic() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_success_without_valid_envelope_remains_transport_failure() {
|
||||
fn resource_envelope_on_413_uses_code_and_records_canonical_422_mismatch() {
|
||||
let (mut client, session) = setup();
|
||||
let request = client.prepare_fetch(&session, "/item").unwrap();
|
||||
let error = client
|
||||
.complete(
|
||||
request.id,
|
||||
response(StatusCode::BAD_GATEWAY, b"gateway html"),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind, ClientErrorKind::Transport);
|
||||
let request = client.refresh_observed(&session, "/item", 0).unwrap();
|
||||
let error = ProtocolError {
|
||||
code: ProtocolErrorCode::ResourceLimitExceeded,
|
||||
message: "logical observation limit exceeded".into(),
|
||||
};
|
||||
let mut encoded =
|
||||
encode_protocol_error_response(ProtocolInteraction::Observe, &error, limits()).unwrap();
|
||||
*encoded.status_mut() = StatusCode::PAYLOAD_TOO_LARGE;
|
||||
|
||||
assert_eq!(
|
||||
client.complete(request.id, encoded).unwrap(),
|
||||
Completion::ProtocolFailure(error)
|
||||
);
|
||||
assert_eq!(
|
||||
client.diagnostics(&session).unwrap().back().unwrap().kind,
|
||||
DiagnosticKind::HttpStatusMismatch
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_success_without_valid_envelope_remains_transport_failure() {
|
||||
for status in [StatusCode::BAD_GATEWAY, StatusCode::PAYLOAD_TOO_LARGE] {
|
||||
let (mut client, session) = setup();
|
||||
let request = client.refresh_observed(&session, "/item", 0).unwrap();
|
||||
let error = client
|
||||
.complete(request.id, response(status, b"infrastructure failure"))
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind, ClientErrorKind::Transport);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_descriptor_format_is_distinct_from_invalid_response() {
|
||||
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 {
|
||||
interface: "math".into(),
|
||||
interface: root_reference("math"),
|
||||
};
|
||||
let value = FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor("future-format/2"),
|
||||
validator: Some(b"v2".to_vec()),
|
||||
};
|
||||
@@ -176,30 +208,37 @@ fn unsupported_descriptor_format_is_distinct_from_invalid_response() {
|
||||
#[test]
|
||||
fn malformed_call_success_after_dispatch_is_outcome_unknown_and_stales_object() {
|
||||
let (mut client, session) = setup();
|
||||
let fetch = client.prepare_fetch(&session, "/item").unwrap();
|
||||
let observation = client.refresh_observed(&session, "/item", 0).unwrap();
|
||||
client
|
||||
.complete(
|
||||
fetch.id,
|
||||
encode_fetch_response(
|
||||
&FetchRequest {
|
||||
observation.id,
|
||||
encode_observe_response(
|
||||
&ObserveRequest {
|
||||
path: "/item".into(),
|
||||
depth: 0,
|
||||
},
|
||||
&ObjectObservation {
|
||||
object: object(),
|
||||
children: None,
|
||||
},
|
||||
&object(),
|
||||
limits(),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let interface = client.prepare_interface(&session, "math").unwrap();
|
||||
let interface = client
|
||||
.prepare_interface(&session, root_reference("math"))
|
||||
.unwrap();
|
||||
client
|
||||
.complete(
|
||||
interface.id,
|
||||
encode_fetch_interface_response(
|
||||
&FetchInterfaceRequest {
|
||||
interface: "math".into(),
|
||||
interface: root_reference("math"),
|
||||
},
|
||||
&FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(INTERFACE_FORMAT_V1),
|
||||
validator: None,
|
||||
},
|
||||
@@ -212,7 +251,7 @@ fn malformed_call_success_after_dispatch_is_outcome_unknown_and_stales_object()
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/item",
|
||||
"math",
|
||||
&root_reference("math"),
|
||||
"calculate",
|
||||
BTreeMap::from([("value".into(), Value::Integer(2))]),
|
||||
)
|
||||
@@ -245,3 +284,83 @@ fn malformed_call_success_after_dispatch_is_outcome_unknown_and_stales_object()
|
||||
);
|
||||
assert_eq!(client.in_flight_len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_413_after_call_dispatch_is_unknown_transport_outcome() {
|
||||
let (mut client, session) = setup();
|
||||
let observation = client.refresh_observed(&session, "/item", 0).unwrap();
|
||||
client
|
||||
.complete(
|
||||
observation.id,
|
||||
encode_observe_response(
|
||||
&ObserveRequest {
|
||||
path: "/item".into(),
|
||||
depth: 0,
|
||||
},
|
||||
&ObjectObservation {
|
||||
object: object(),
|
||||
children: None,
|
||||
},
|
||||
limits(),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let interface = client
|
||||
.prepare_interface(&session, root_reference("math"))
|
||||
.unwrap();
|
||||
client
|
||||
.complete(
|
||||
interface.id,
|
||||
encode_fetch_interface_response(
|
||||
&FetchInterfaceRequest {
|
||||
interface: root_reference("math"),
|
||||
},
|
||||
&FetchInterfaceResponse {
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(INTERFACE_FORMAT_V1),
|
||||
validator: None,
|
||||
},
|
||||
limits(),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let call = client
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/item",
|
||||
&root_reference("math"),
|
||||
"calculate",
|
||||
BTreeMap::from([("value".into(), Value::Integer(2))]),
|
||||
)
|
||||
.unwrap();
|
||||
client.mark_dispatched(call.id).unwrap();
|
||||
|
||||
let completion = client
|
||||
.complete(
|
||||
call.id,
|
||||
Response::builder()
|
||||
.status(StatusCode::PAYLOAD_TOO_LARGE)
|
||||
.body(Vec::<u8>::new())
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
completion,
|
||||
Completion::Call(record)
|
||||
if matches!(
|
||||
&record.outcome,
|
||||
CallOutcome::Unknown {
|
||||
reason: OutcomeUnknownReason::ResponseDecode,
|
||||
failure: Some(failure),
|
||||
} if failure.kind == ClientErrorKind::Transport
|
||||
)
|
||||
));
|
||||
assert_eq!(
|
||||
client.object(&session, "/item").unwrap().state,
|
||||
ObservationState::Stale
|
||||
);
|
||||
assert_eq!(client.in_flight_len(), 0);
|
||||
}
|
||||
|
||||
+1005
-179
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,21 @@
|
||||
[package]
|
||||
name = "wip-fs-server"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Request-time filesystem projection server for WIP over HTTPS"
|
||||
license = "MIT OR Apache-2.0"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Extensible request-time filesystem projection library and WIP over HTTP server"
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
readme = "README.md"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
http = "1"
|
||||
rustix = { version = "1.1", features = ["fs"] }
|
||||
wip-host = { path = "../wip-host" }
|
||||
wip-https = { path = "../wip-https" }
|
||||
wip-interface = { path = "../wip-interface" }
|
||||
wip-protocol = { path = "../wip-protocol" }
|
||||
wip-host.workspace = true
|
||||
wip-http.workspace = true
|
||||
wip-interface.workspace = true
|
||||
wip-protocol.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
+146
-37
@@ -3,8 +3,121 @@
|
||||
`wip-fs-server` projects one configured Linux filesystem directory onto WIP
|
||||
Worldspace `/` at request time. It does not register the filesystem contents or
|
||||
keep one object instance per entry. It uses `wip-host` for publication and typed
|
||||
operation dispatch and uses the standard `wip-https` routes, JSON codecs,
|
||||
envelopes, batching, and HTTP status mapping.
|
||||
operation dispatch and uses the standard `wip-http` routes, JSON codecs,
|
||||
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
|
||||
|
||||
@@ -28,9 +141,9 @@ envelopes, batching, and HTTP status mapping.
|
||||
- Protocol metadata and errors never contain the backend absolute path. Startup
|
||||
prints only the explicitly configured effective root and security-sensitive
|
||||
modes.
|
||||
- The built-in listener is cleartext and loopback-only. Production deployments
|
||||
must pass `--behind-tls-proxy` and terminate HTTPS at a trusted reverse proxy.
|
||||
Plain development HTTP requires the explicit `--development-http` flag.
|
||||
- The built-in HTTP listener is loopback-only. TLS termination, certificates,
|
||||
authentication, an externally visible URL scheme, and proxy routing are
|
||||
deployment concerns rather than separate WIP transport profiles.
|
||||
|
||||
The implementation currently targets Linux because it uses `O_PATH`,
|
||||
directory-relative descriptor traversal, and `/proc/self/fd` to reopen the same
|
||||
@@ -38,59 +151,59 @@ pinned backend object without resolving the untrusted Worldspace path twice.
|
||||
|
||||
## Run
|
||||
|
||||
Read-only behind a TLS-terminating reverse proxy:
|
||||
Run the loopback listener in read-only mode:
|
||||
|
||||
```console
|
||||
cargo run -p wip-fs-server -- \
|
||||
--root /srv/public-files \
|
||||
--listen 127.0.0.1:8443 \
|
||||
--base-path /wip \
|
||||
--behind-tls-proxy
|
||||
--listen 127.0.0.1:8080 \
|
||||
--base-path /wip
|
||||
```
|
||||
|
||||
Local development with the write capability deliberately enabled:
|
||||
Run with the write capability deliberately enabled:
|
||||
|
||||
```console
|
||||
cargo run -p wip-fs-server -- \
|
||||
--root ./example-files \
|
||||
--listen 127.0.0.1:8080 \
|
||||
--base-path /wip \
|
||||
--development-http \
|
||||
--read-write
|
||||
```
|
||||
|
||||
The server reports the effective root, `ReadOnly`/`ReadWrite` mode, transport
|
||||
mode, endpoint, and actual listen address. It does not enumerate the root.
|
||||
The server reports the effective root, `ReadOnly`/`ReadWrite` mode, endpoint,
|
||||
and actual listen address. It does not enumerate the root.
|
||||
|
||||
## Standard interactions
|
||||
|
||||
The following examples use development HTTP for readability. The JSON is the
|
||||
standard `wip-https` profile; validators and operation `Bytes` are base64 body
|
||||
The following examples use the built-in HTTP listener. The JSON follows the
|
||||
standard WIP over HTTP binding; validators and operation `Bytes` are base64 body
|
||||
fields, never `ETag` or `If-Match` headers.
|
||||
|
||||
Fetch root:
|
||||
Observe only the root Object:
|
||||
|
||||
```console
|
||||
curl -sS -X POST http://127.0.0.1:8080/wip/v1/fetch \
|
||||
curl -sS -X POST http://127.0.0.1:8080/wip/v1/observe \
|
||||
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||
--data '{"path":"/"}'
|
||||
--data '{"path":"/","depth":0}'
|
||||
```
|
||||
|
||||
Fetch a bounded tree (the root counts toward `limit`):
|
||||
Observe the root and two levels of indexable children:
|
||||
|
||||
```console
|
||||
curl -sS -X POST http://127.0.0.1:8080/wip/v1/fetch_tree \
|
||||
curl -sS -X POST http://127.0.0.1:8080/wip/v1/observe \
|
||||
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||
--data '{"path":"/","depth":2,"limit":100}'
|
||||
--data '{"path":"/","depth":2}'
|
||||
```
|
||||
|
||||
A regular-file fetch returns the opaque read interface reference and an object
|
||||
validator. Retrieve that descriptor with:
|
||||
A regular-file observation returns a structured read interface reference and an
|
||||
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
|
||||
curl -sS -X POST http://127.0.0.1:8080/wip/v1/fetch_interface \
|
||||
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||
--data '{"interface":"<read-interface-from-fetch>"}'
|
||||
--data '{"interface":{"scope":"/","name":"<read-interface-name-from-observation>"}}'
|
||||
```
|
||||
|
||||
Then call a bounded range read:
|
||||
@@ -100,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' \
|
||||
--data '{
|
||||
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
|
||||
"interface":{"reference":"<read-interface>"},
|
||||
"interface":{"reference":{"scope":"/","name":"<read-interface-name>"}},
|
||||
"operation":"read",
|
||||
"arguments":{"offset":0,"length":4096}
|
||||
}'
|
||||
@@ -114,31 +227,27 @@ curl -sS -X POST http://127.0.0.1:8080/wip/v1/call_operation \
|
||||
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||
--data '{
|
||||
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
|
||||
"interface":{"reference":"<write-interface>"},
|
||||
"interface":{"reference":{"scope":"/","name":"<write-interface-name>"}},
|
||||
"operation":"replace",
|
||||
"arguments":{"contents":"dXBkYXRlZA=="}
|
||||
}'
|
||||
```
|
||||
|
||||
`fetch`, `fetch_tree`, and `fetch_interface` can also be combined through the
|
||||
standard `POST /wip/v1/batch` route. `call_operation` is never batchable.
|
||||
|
||||
## Resource and failure behavior
|
||||
|
||||
`ResourceLimits` bounds request/response bytes, HTTP request-head bytes, JSON
|
||||
nesting, batch items, file read/write bytes, tree depth/node count, pinned handles,
|
||||
and active connections/requests. One absolute request deadline covers header and
|
||||
nesting, file read/write bytes, tree depth/node count, pinned handles, and active
|
||||
connections/requests. One absolute request deadline covers header and
|
||||
body intake, dispatch, cooperative filesystem work, and response transmission;
|
||||
stalled clients are closed and cannot occupy a worker beyond that bound. Workers
|
||||
accept connections directly, so the listener never moves more than the configured
|
||||
concurrency into active userspace service. Advisory file-lock acquisition uses a
|
||||
nonblocking loop and checks the same deadline/cancellation rather than allowing a
|
||||
contended lock to stall a worker. Tree traversal is breadth-first and opens only
|
||||
requested entries. Once a limit or depth boundary is reached it reads only far
|
||||
enough to
|
||||
prove whether an additional visible child exists, so `truncated` is true only
|
||||
when something was actually omitted. I/O failure and cancellation remain errors,
|
||||
not clean exhaustion.
|
||||
contended lock to stall a worker. Object observation recursively opens only the
|
||||
requested depth. A successful response is complete through that depth; if the
|
||||
configured projected-node budget would be exceeded, the interaction fails with
|
||||
`ResourceLimitExceeded` instead of returning a partial observation. I/O failure
|
||||
and cancellation remain errors.
|
||||
|
||||
Whole-file replacement compares the request validator against the identity and
|
||||
state of the same pinned file handle used for the write. Failures before mutation
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,52 @@
|
||||
//! Request-time filesystem projection served through the standard WIP HTTPS binding.
|
||||
//! Request-time filesystem projection served through the standard WIP over HTTP binding.
|
||||
//!
|
||||
//! This crate deliberately keeps the boundaries visible:
|
||||
//!
|
||||
//! - [`wip_protocol`] owns logical requests and responses;
|
||||
//! - [`wip_host`] owns object publication, validators, and typed dispatch;
|
||||
//! - [`wip_https`] owns routes, JSON, envelopes, batching, and status mapping;
|
||||
//! - [`wip_http`] owns routes, JSON, envelopes, and status mapping;
|
||||
//! - this crate owns the filesystem capability boundary and the real HTTP listener.
|
||||
//!
|
||||
//! TLS is terminated by an explicitly configured reverse proxy. The built-in
|
||||
//! listener accepts cleartext only on loopback, either behind that proxy or when
|
||||
//! [`TransportSecurity::DevelopmentHttp`] is deliberately selected.
|
||||
//! # 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,
|
||||
//! certificates, authentication, and externally visible schemes are independent
|
||||
//! deployment concerns rather than WIP transport profiles.
|
||||
|
||||
#![deny(missing_docs)]
|
||||
|
||||
mod defaults;
|
||||
mod engine;
|
||||
mod http_adapter;
|
||||
mod interfaces;
|
||||
mod projection;
|
||||
mod server;
|
||||
@@ -21,7 +55,17 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::path::PathBuf;
|
||||
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};
|
||||
|
||||
/// Filesystem capabilities published by regular files.
|
||||
@@ -33,28 +77,19 @@ pub enum AccessMode {
|
||||
ReadWrite,
|
||||
}
|
||||
|
||||
/// How HTTPS is provided for the endpoint.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TransportSecurity {
|
||||
/// A trusted reverse proxy terminates TLS and forwards to the loopback listener.
|
||||
ReverseProxyTls,
|
||||
/// Cleartext HTTP is deliberately enabled for local development only.
|
||||
DevelopmentHttp,
|
||||
}
|
||||
|
||||
/// Bounded resource policy applied by the HTTP adapter and filesystem provider.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ResourceLimits {
|
||||
/// Standard JSON, body-size, nesting, and batch limits.
|
||||
pub wire: wip_https::Limits,
|
||||
/// Standard JSON, body-size, and nesting limits.
|
||||
pub wire: wip_http::Limits,
|
||||
/// Maximum bytes returned by one file read.
|
||||
pub max_file_read_bytes: usize,
|
||||
/// Maximum bytes accepted by one file replacement.
|
||||
pub max_file_write_bytes: usize,
|
||||
/// Maximum accepted `fetch_tree` depth.
|
||||
pub max_tree_depth: u32,
|
||||
/// Maximum accepted `fetch_tree` node count, including its root.
|
||||
pub max_tree_nodes: u32,
|
||||
/// Maximum accepted `observe` depth.
|
||||
pub max_observation_depth: u32,
|
||||
/// Maximum accepted `observe` node count, including its root.
|
||||
pub max_observation_nodes: u32,
|
||||
/// Maximum simultaneously pinned projected filesystem handles.
|
||||
pub max_open_handles: usize,
|
||||
/// Maximum bytes accepted for one HTTP request head, including its terminator.
|
||||
@@ -68,12 +103,12 @@ pub struct ResourceLimits {
|
||||
impl Default for ResourceLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
wire: wip_https::Limits::new(256 * 1024, 2 * 1024 * 1024, 64, 64)
|
||||
wire: wip_http::Limits::new(256 * 1024, 2 * 1024 * 1024, 64)
|
||||
.expect("default wire limits are nonzero"),
|
||||
max_file_read_bytes: 1024 * 1024,
|
||||
max_file_write_bytes: 1024 * 1024,
|
||||
max_tree_depth: 32,
|
||||
max_tree_nodes: 1024,
|
||||
max_observation_depth: 32,
|
||||
max_observation_nodes: 1024,
|
||||
max_open_handles: 2048,
|
||||
max_http_header_bytes: 16 * 1024,
|
||||
max_concurrent_requests: 32,
|
||||
@@ -93,8 +128,6 @@ pub struct ServerConfig {
|
||||
pub endpoint_base_path: String,
|
||||
/// Read-only or explicit read-write publication mode.
|
||||
pub access: AccessMode,
|
||||
/// TLS termination or explicit development HTTP policy.
|
||||
pub transport_security: TransportSecurity,
|
||||
/// Request, operation, traversal, and concurrency bounds.
|
||||
pub limits: ResourceLimits,
|
||||
}
|
||||
@@ -102,8 +135,7 @@ pub struct ServerConfig {
|
||||
impl ServerConfig {
|
||||
/// Builds a secure-by-default configuration for `root`.
|
||||
///
|
||||
/// The listener defaults to an ephemeral loopback port, read-only access,
|
||||
/// and TLS termination delegated to a reverse proxy.
|
||||
/// The listener defaults to an ephemeral loopback port and read-only access.
|
||||
#[must_use]
|
||||
pub fn for_root(root: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
@@ -111,7 +143,6 @@ impl ServerConfig {
|
||||
listen: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
|
||||
endpoint_base_path: "/wip".into(),
|
||||
access: AccessMode::ReadOnly,
|
||||
transport_security: TransportSecurity::ReverseProxyTls,
|
||||
limits: ResourceLimits::default(),
|
||||
}
|
||||
}
|
||||
@@ -125,7 +156,7 @@ impl ServerConfig {
|
||||
}
|
||||
if self.limits.max_file_read_bytes == 0
|
||||
|| self.limits.max_file_write_bytes == 0
|
||||
|| self.limits.max_tree_nodes == 0
|
||||
|| self.limits.max_observation_nodes == 0
|
||||
|| self.limits.max_open_handles == 0
|
||||
|| self.limits.max_http_header_bytes == 0
|
||||
|| self.limits.max_concurrent_requests == 0
|
||||
@@ -133,7 +164,7 @@ impl ServerConfig {
|
||||
{
|
||||
return Err(ConfigError::ZeroLimit);
|
||||
}
|
||||
if self.limits.max_open_handles < self.limits.max_tree_nodes as usize {
|
||||
if self.limits.max_open_handles < self.limits.max_observation_nodes as usize {
|
||||
return Err(ConfigError::InsufficientHandles);
|
||||
}
|
||||
if self.limits.wire.max_response_bytes() < 512 {
|
||||
@@ -142,9 +173,9 @@ impl ServerConfig {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn endpoint(&self) -> Result<wip_https::Endpoint, ConfigError> {
|
||||
let endpoint = format!("https://localhost{}", self.endpoint_base_path);
|
||||
wip_https::Endpoint::parse(&endpoint).map_err(|_| ConfigError::InvalidBasePath)
|
||||
pub(crate) fn endpoint(&self) -> Result<wip_http::Endpoint, ConfigError> {
|
||||
let endpoint = format!("http://localhost{}", self.endpoint_base_path);
|
||||
wip_http::Endpoint::parse(&endpoint).map_err(|_| ConfigError::InvalidBasePath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +202,7 @@ impl std::fmt::Display for ConfigError {
|
||||
"endpoint base path must be absolute, canonical, and query-free"
|
||||
}
|
||||
Self::ZeroLimit => "all resource limits and the request timeout must be nonzero",
|
||||
Self::InsufficientHandles => "max_open_handles must cover max_tree_nodes",
|
||||
Self::InsufficientHandles => "max_open_handles must cover max_observation_nodes",
|
||||
Self::ResponseLimitTooSmall => "max_response_bytes must be at least 512",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,14 +2,13 @@ use std::error::Error;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use wip_fs_server::{AccessMode, ServerConfig, TransportSecurity, WipFsServer};
|
||||
use wip_fs_server::{AccessMode, ServerConfig, WipFsServer};
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let mut root: Option<PathBuf> = None;
|
||||
let mut listen: SocketAddr = "127.0.0.1:8443".parse()?;
|
||||
let mut listen: SocketAddr = "127.0.0.1:8080".parse()?;
|
||||
let mut base_path = "/wip".to_owned();
|
||||
let mut access = AccessMode::ReadOnly;
|
||||
let mut security = None;
|
||||
let mut arguments = std::env::args().skip(1);
|
||||
while let Some(argument) = arguments.next() {
|
||||
match argument.as_str() {
|
||||
@@ -17,12 +16,6 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
"--listen" => listen = required_value(&mut arguments, "--listen")?.parse()?,
|
||||
"--base-path" => base_path = required_value(&mut arguments, "--base-path")?,
|
||||
"--read-write" => access = AccessMode::ReadWrite,
|
||||
"--development-http" => {
|
||||
set_security(&mut security, TransportSecurity::DevelopmentHttp)?
|
||||
}
|
||||
"--behind-tls-proxy" => {
|
||||
set_security(&mut security, TransportSecurity::ReverseProxyTls)?
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
print_help();
|
||||
return Ok(());
|
||||
@@ -32,20 +25,17 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
}
|
||||
|
||||
let root = root.ok_or("--root is required")?;
|
||||
let transport_security =
|
||||
security.ok_or("select exactly one of --behind-tls-proxy or --development-http")?;
|
||||
let mut config = ServerConfig::for_root(root);
|
||||
config.listen = listen;
|
||||
config.endpoint_base_path = base_path;
|
||||
config.access = access;
|
||||
config.transport_security = transport_security;
|
||||
|
||||
let server = WipFsServer::new(config)?;
|
||||
let effective_root = server.effective_root().to_owned();
|
||||
let endpoint = server.endpoint().clone();
|
||||
let bound = server.bind()?;
|
||||
eprintln!(
|
||||
"wip-fs-server root={} access={access:?} transport={transport_security:?} endpoint={} listen={}",
|
||||
"wip-fs-server root={} access={access:?} endpoint={} listen={}",
|
||||
effective_root.display(),
|
||||
endpoint,
|
||||
bound.local_addr()
|
||||
@@ -63,21 +53,10 @@ fn required_value(
|
||||
.ok_or_else(|| format!("{option} requires a value").into())
|
||||
}
|
||||
|
||||
fn set_security(
|
||||
security: &mut Option<TransportSecurity>,
|
||||
value: TransportSecurity,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
if security.replace(value).is_some() {
|
||||
Err("select only one transport-security mode".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
println!(
|
||||
"Usage: wip-fs-server --root PATH [--listen 127.0.0.1:8443] \\\n [--base-path /wip] [--read-write] \\\n (--behind-tls-proxy | --development-http)\n\n\
|
||||
The built-in listener is cleartext and loopback-only. In production, \
|
||||
select --behind-tls-proxy and terminate HTTPS at a trusted reverse proxy."
|
||||
"Usage: wip-fs-server --root PATH [--listen 127.0.0.1:8080] \\\n [--base-path /wip] [--read-write]\n\n\
|
||||
The built-in HTTP listener is loopback-only. TLS termination, authentication, \
|
||||
and external routing are deployment concerns."
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,28 +6,19 @@ use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use http::{Request, Response, StatusCode};
|
||||
use wip_host::{ExecutionContext, ObjectProvider};
|
||||
use wip_https::{
|
||||
BatchRequestKind, BatchResponse, BatchResponseItem, BatchSuccess, CodecError, EncodeHttpError,
|
||||
Endpoint, RequestMetadataError, ResourceLimitKind, Route, decode_batch_request,
|
||||
decode_call_operation_metadata, decode_fetch_interface_request, decode_fetch_request,
|
||||
decode_fetch_tree_request, encode_batch_error_response,
|
||||
encode_batch_resource_limit_error_response, encode_batch_response,
|
||||
encode_call_operation_response, encode_fetch_interface_response, encode_fetch_response,
|
||||
encode_fetch_tree_response, encode_protocol_error_response,
|
||||
encode_resource_limit_error_response, validate_http_request,
|
||||
};
|
||||
use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction};
|
||||
use wip_host::ExecutionContext;
|
||||
use wip_http::Endpoint;
|
||||
|
||||
use crate::projection::Projection;
|
||||
use crate::{ConfigError, ServerConfig};
|
||||
use crate::engine::Projection;
|
||||
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 {
|
||||
config: ServerConfig,
|
||||
endpoint: Endpoint,
|
||||
effective_root: std::path::PathBuf,
|
||||
projection: Arc<Projection>,
|
||||
projection: Arc<Projection<LinuxBackend, DefaultComposition, DefaultPolicy>>,
|
||||
}
|
||||
|
||||
impl WipFsServer {
|
||||
@@ -35,8 +26,8 @@ impl WipFsServer {
|
||||
pub fn new(config: ServerConfig) -> Result<Self, ServerBuildError> {
|
||||
config.validate()?;
|
||||
let endpoint = config.endpoint()?;
|
||||
let projection = Projection::open(&config.root, config.access, config.limits)?;
|
||||
let effective_root = projection.effective_root()?;
|
||||
let projection = crate::default_projection(&config.root, config.access, config.limits)?;
|
||||
let effective_root = projection.backend().effective_root()?;
|
||||
Ok(Self {
|
||||
config,
|
||||
endpoint,
|
||||
@@ -57,7 +48,7 @@ impl WipFsServer {
|
||||
&self.endpoint
|
||||
}
|
||||
|
||||
/// Handles one already-buffered HTTP request with the standard `wip-https` codecs.
|
||||
/// Handles one already-buffered HTTP request with the standard `wip-http` codecs.
|
||||
///
|
||||
/// Listener integrations must enforce the same body limit while collecting bytes.
|
||||
#[must_use]
|
||||
@@ -70,28 +61,19 @@ impl WipFsServer {
|
||||
request: Request<Vec<u8>>,
|
||||
deadline: Instant,
|
||||
) -> Response<Vec<u8>> {
|
||||
let Some(route) = self.route(request.uri().path()) else {
|
||||
return empty_response(StatusCode::NOT_FOUND);
|
||||
};
|
||||
if let Err(error) = validate_http_request(&self.endpoint, route, &request) {
|
||||
return self.metadata_error(route, error);
|
||||
}
|
||||
let context = ExecutionContext::new()
|
||||
.with_deadline(deadline)
|
||||
.with_resource_budget(u64::from(self.config.limits.max_tree_nodes));
|
||||
match route {
|
||||
Route::Fetch => self.fetch(request.body(), &context),
|
||||
Route::FetchTree => self.fetch_tree(request.body(), &context),
|
||||
Route::FetchInterface => self.fetch_interface(request.body()),
|
||||
Route::CallOperation => self.call_operation(request.body(), &context),
|
||||
Route::Batch => self.batch(request.body(), &context),
|
||||
}
|
||||
.with_resource_budget(u64::from(self.config.limits.max_observation_nodes));
|
||||
let view = self.projection.view(&());
|
||||
HttpAdapter::new(&view, &self.endpoint, self.config.limits)
|
||||
.handle_http_with_context(request, &context)
|
||||
}
|
||||
|
||||
/// Binds the built-in bounded loopback HTTP listener.
|
||||
///
|
||||
/// The listener is cleartext by design. Configuration must explicitly state
|
||||
/// whether TLS is terminated by a reverse proxy or development HTTP is in use.
|
||||
/// The built-in listener is cleartext and loopback-only. Externally visible
|
||||
/// schemes, TLS termination, authentication, and proxy routing are deployment
|
||||
/// concerns rather than WIP transport profiles.
|
||||
pub fn bind(self) -> Result<BoundServer, ServerBuildError> {
|
||||
let listener = TcpListener::bind(self.config.listen)
|
||||
.map_err(|error| ServerBuildError::Listener(error.to_string()))?;
|
||||
@@ -107,259 +89,6 @@ impl WipFsServer {
|
||||
listener: Arc::new(listener),
|
||||
})
|
||||
}
|
||||
|
||||
fn route(&self, path: &str) -> Option<Route> {
|
||||
[
|
||||
Route::Fetch,
|
||||
Route::FetchTree,
|
||||
Route::FetchInterface,
|
||||
Route::CallOperation,
|
||||
Route::Batch,
|
||||
]
|
||||
.into_iter()
|
||||
.find(|route| self.endpoint.route(*route).path() == path)
|
||||
}
|
||||
|
||||
fn fetch(&self, body: &[u8], context: &ExecutionContext) -> Response<Vec<u8>> {
|
||||
let request = match decode_fetch_request(body, self.config.limits.wire) {
|
||||
Ok(request) => request,
|
||||
Err(error) => return self.codec_error(ProtocolInteraction::Fetch, error),
|
||||
};
|
||||
match self
|
||||
.projection
|
||||
.fetch_with_context(context, &request.path)
|
||||
.map_err(|error| error.protocol_error())
|
||||
{
|
||||
Ok(object) => encode_fetch_response(&request, &object, self.config.limits.wire)
|
||||
.unwrap_or_else(|error| self.read_encode_error(ProtocolInteraction::Fetch, error)),
|
||||
Err(error) => self.protocol_error(ProtocolInteraction::Fetch, &error, None),
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_tree(&self, body: &[u8], context: &ExecutionContext) -> Response<Vec<u8>> {
|
||||
let request = match decode_fetch_tree_request(body, self.config.limits.wire) {
|
||||
Ok(request) => request,
|
||||
Err(error) => return self.codec_error(ProtocolInteraction::FetchTree, error),
|
||||
};
|
||||
match self.projection.fetch_tree(context, &request) {
|
||||
Ok(tree) => encode_fetch_tree_response(&request, &tree, self.config.limits.wire)
|
||||
.unwrap_or_else(|error| {
|
||||
self.read_encode_error(ProtocolInteraction::FetchTree, error)
|
||||
}),
|
||||
Err(error) => self.protocol_error(ProtocolInteraction::FetchTree, &error, None),
|
||||
}
|
||||
}
|
||||
|
||||
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, None),
|
||||
}
|
||||
}
|
||||
|
||||
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, None);
|
||||
};
|
||||
encode_call_operation_response(
|
||||
&request,
|
||||
&descriptor,
|
||||
&result,
|
||||
self.config.limits.wire,
|
||||
)
|
||||
.unwrap_or_else(|encode_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, None)
|
||||
})
|
||||
}
|
||||
Err(error) => self.protocol_error(ProtocolInteraction::CallOperation, &error, None),
|
||||
}
|
||||
}
|
||||
|
||||
fn batch(&self, body: &[u8], context: &ExecutionContext) -> Response<Vec<u8>> {
|
||||
let request = match decode_batch_request(body, self.config.limits.wire) {
|
||||
Ok(request) => request,
|
||||
Err(error) => return self.batch_codec_error(error),
|
||||
};
|
||||
let responses = request
|
||||
.requests
|
||||
.iter()
|
||||
.map(|item| {
|
||||
let outcome = match &item.request {
|
||||
BatchRequestKind::Fetch(request) => self
|
||||
.projection
|
||||
.fetch_with_context(context, &request.path)
|
||||
.map(BatchSuccess::Fetch)
|
||||
.map_err(|error| error.protocol_error()),
|
||||
BatchRequestKind::FetchTree(request) => self
|
||||
.projection
|
||||
.fetch_tree(context, request)
|
||||
.map(BatchSuccess::FetchTree),
|
||||
BatchRequestKind::FetchInterface(request) => self
|
||||
.projection
|
||||
.fetch_interface(request)
|
||||
.map(BatchSuccess::FetchInterface),
|
||||
BatchRequestKind::Invalid { error, .. } => Err(error.clone()),
|
||||
};
|
||||
BatchResponseItem {
|
||||
id: item.id.clone(),
|
||||
outcome,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let response = BatchResponse { responses };
|
||||
encode_batch_response(&request, &response, self.config.limits.wire).unwrap_or_else(
|
||||
|error| match error {
|
||||
EncodeHttpError::Codec(codec) => self.batch_codec_error(codec),
|
||||
EncodeHttpError::Http(_) => self.batch_top_error(&internal_error(), None),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn metadata_error(&self, route: Route, error: RequestMetadataError) -> Response<Vec<u8>> {
|
||||
match error {
|
||||
RequestMetadataError::InvalidRequest(_) => self.protocol_error(
|
||||
interaction(route),
|
||||
&ProtocolError {
|
||||
code: ProtocolErrorCode::InvalidRequest,
|
||||
message: "invalid HTTP request metadata".into(),
|
||||
},
|
||||
None,
|
||||
),
|
||||
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 let Some(kind) = error.resource_limit_kind() {
|
||||
self.protocol_error(interaction, &resource_error(), Some(kind))
|
||||
} else {
|
||||
self.protocol_error(
|
||||
interaction,
|
||||
&ProtocolError {
|
||||
code: ProtocolErrorCode::InvalidRequest,
|
||||
message: "malformed WIP request".into(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn batch_codec_error(&self, error: CodecError) -> Response<Vec<u8>> {
|
||||
if let Some(kind) = error.resource_limit_kind() {
|
||||
self.batch_top_error(&resource_error(), Some(kind))
|
||||
} else {
|
||||
self.batch_top_error(
|
||||
&ProtocolError {
|
||||
code: ProtocolErrorCode::InvalidRequest,
|
||||
message: "malformed WIP batch request".into(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_encode_error(
|
||||
&self,
|
||||
interaction: ProtocolInteraction,
|
||||
error: EncodeHttpError,
|
||||
) -> Response<Vec<u8>> {
|
||||
match error {
|
||||
EncodeHttpError::Codec(codec) if codec.resource_limit_kind().is_some() => {
|
||||
self.protocol_error(interaction, &resource_error(), codec.resource_limit_kind())
|
||||
}
|
||||
_ => self.protocol_error(interaction, &internal_error(), None),
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol_error(
|
||||
&self,
|
||||
interaction: ProtocolInteraction,
|
||||
error: &ProtocolError,
|
||||
resource_kind: Option<ResourceLimitKind>,
|
||||
) -> Response<Vec<u8>> {
|
||||
let encoded = match resource_kind {
|
||||
Some(kind) => encode_resource_limit_error_response(
|
||||
interaction,
|
||||
error,
|
||||
kind,
|
||||
self.config.limits.wire,
|
||||
),
|
||||
None => encode_protocol_error_response(interaction, error, self.config.limits.wire),
|
||||
};
|
||||
encoded.unwrap_or_else(|_| empty_response(StatusCode::INTERNAL_SERVER_ERROR))
|
||||
}
|
||||
|
||||
fn batch_top_error(
|
||||
&self,
|
||||
error: &ProtocolError,
|
||||
resource_kind: Option<ResourceLimitKind>,
|
||||
) -> Response<Vec<u8>> {
|
||||
let encoded = match resource_kind {
|
||||
Some(kind) => {
|
||||
encode_batch_resource_limit_error_response(error, kind, self.config.limits.wire)
|
||||
}
|
||||
None => encode_batch_error_response(error, self.config.limits.wire),
|
||||
};
|
||||
encoded.unwrap_or_else(|_| empty_response(StatusCode::INTERNAL_SERVER_ERROR))
|
||||
}
|
||||
}
|
||||
|
||||
/// Bound real HTTP listener for a [`WipFsServer`].
|
||||
@@ -428,7 +157,7 @@ struct ParsedRequestHead {
|
||||
enum IntakeError {
|
||||
Timeout,
|
||||
HeaderTooLarge,
|
||||
PayloadTooLarge { target: String },
|
||||
PayloadTooLarge,
|
||||
BadRequest,
|
||||
Socket,
|
||||
}
|
||||
@@ -449,9 +178,7 @@ fn serve_connection(service: &WipFsServer, mut stream: TcpStream) {
|
||||
Err(IntakeError::HeaderTooLarge) => {
|
||||
Some(empty_response(StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE))
|
||||
}
|
||||
Err(IntakeError::PayloadTooLarge { target }) => {
|
||||
Some(payload_too_large_response(service, &target))
|
||||
}
|
||||
Err(IntakeError::PayloadTooLarge) => Some(empty_response(StatusCode::PAYLOAD_TOO_LARGE)),
|
||||
Err(IntakeError::BadRequest) => Some(empty_response(StatusCode::BAD_REQUEST)),
|
||||
Err(IntakeError::Timeout | IntakeError::Socket) => None,
|
||||
};
|
||||
@@ -490,9 +217,7 @@ fn read_http_request(
|
||||
|
||||
let parsed = parse_request_head(&received[..header_end])?;
|
||||
if parsed.content_length > maximum_body {
|
||||
return Err(IntakeError::PayloadTooLarge {
|
||||
target: parsed.target,
|
||||
});
|
||||
return Err(IntakeError::PayloadTooLarge);
|
||||
}
|
||||
let mut body = Vec::with_capacity(parsed.content_length);
|
||||
let buffered = &received[header_end..];
|
||||
@@ -591,27 +316,6 @@ fn read_with_deadline(
|
||||
})
|
||||
}
|
||||
|
||||
fn payload_too_large_response(service: &WipFsServer, target: &str) -> Response<Vec<u8>> {
|
||||
let route = service.route(target.split('?').next().unwrap_or_default());
|
||||
route.map_or_else(
|
||||
|| empty_response(StatusCode::PAYLOAD_TOO_LARGE),
|
||||
|route| {
|
||||
if route == Route::Batch {
|
||||
service.batch_top_error(
|
||||
&resource_error(),
|
||||
Some(ResourceLimitKind::RepresentationBytes),
|
||||
)
|
||||
} else {
|
||||
service.protocol_error(
|
||||
interaction(route),
|
||||
&resource_error(),
|
||||
Some(ResourceLimitKind::RepresentationBytes),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn write_http_response(
|
||||
stream: &mut TcpStream,
|
||||
response: &Response<Vec<u8>>,
|
||||
@@ -672,30 +376,6 @@ fn write_all_with_deadline(
|
||||
stream.flush()
|
||||
}
|
||||
|
||||
fn interaction(route: Route) -> ProtocolInteraction {
|
||||
match route {
|
||||
Route::Fetch => ProtocolInteraction::Fetch,
|
||||
Route::FetchTree => ProtocolInteraction::FetchTree,
|
||||
Route::FetchInterface => ProtocolInteraction::FetchInterface,
|
||||
Route::CallOperation => ProtocolInteraction::CallOperation,
|
||||
Route::Batch => ProtocolInteraction::Fetch,
|
||||
}
|
||||
}
|
||||
|
||||
fn resource_error() -> ProtocolError {
|
||||
ProtocolError {
|
||||
code: ProtocolErrorCode::ResourceLimitExceeded,
|
||||
message: "configured resource limit exceeded".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn internal_error() -> ProtocolError {
|
||||
ProtocolError {
|
||||
code: ProtocolErrorCode::Internal,
|
||||
message: "filesystem server failed".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_response(status: StatusCode) -> Response<Vec<u8>> {
|
||||
Response::builder()
|
||||
.status(status)
|
||||
|
||||
@@ -7,28 +7,26 @@ use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tempfile::tempdir;
|
||||
use wip_fs_server::{AccessMode, ServerConfig, TransportSecurity, WipFsServer};
|
||||
use wip_https::{
|
||||
BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse, BatchSuccess, DecodedResponse,
|
||||
Endpoint, JSON_CONTENT_TYPE, Limits, Route, decode_batch_request, decode_batch_response,
|
||||
decode_call_operation_response, decode_fetch_interface_response, decode_fetch_response,
|
||||
decode_fetch_tree_response, encode_batch_request, encode_call_operation_request,
|
||||
encode_fetch_interface_request, encode_fetch_request, encode_fetch_tree_request,
|
||||
use wip_fs_server::{AccessMode, ServerConfig, WipFsServer};
|
||||
use wip_http::{
|
||||
ClientResponseError, DecodedResponse, Endpoint, JSON_CONTENT_TYPE, Limits, Route,
|
||||
TransportFailureKind, decode_call_operation_response, decode_fetch_interface_response,
|
||||
decode_observe_response, encode_call_operation_request, encode_fetch_interface_request,
|
||||
encode_observe_request,
|
||||
};
|
||||
use wip_protocol::{
|
||||
CallOperationRequest, FetchInterfaceRequest, FetchRequest, FetchTreeRequest, InterfaceTarget,
|
||||
CallOperationRequest, FetchInterfaceRequest, InterfaceTarget, ObserveRequest,
|
||||
ProtocolErrorCode, Target, Value,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn real_listener_serves_four_core_interactions_batch_and_write() {
|
||||
fn real_listener_serves_three_core_interactions_and_write() {
|
||||
let temporary = tempdir().expect("temporary root");
|
||||
std::fs::write(temporary.path().join("hello.txt"), b"hello world").expect("write file");
|
||||
std::fs::create_dir(temporary.path().join("nested")).expect("create directory");
|
||||
|
||||
let mut config = ServerConfig::for_root(temporary.path());
|
||||
config.access = AccessMode::ReadWrite;
|
||||
config.transport_security = TransportSecurity::DevelopmentHttp;
|
||||
let limits = config.limits.wire;
|
||||
let bound = Arc::new(
|
||||
WipFsServer::new(config)
|
||||
@@ -43,35 +41,42 @@ fn real_listener_serves_four_core_interactions_batch_and_write() {
|
||||
let shutdown = Arc::clone(&shutdown);
|
||||
thread::spawn(move || bound.serve_until(shutdown))
|
||||
};
|
||||
let endpoint = Endpoint::parse(&format!("https://localhost:{}/wip", address.port()))
|
||||
let endpoint = Endpoint::parse(&format!("http://localhost:{}/wip", address.port()))
|
||||
.expect("client endpoint");
|
||||
|
||||
let file_request = FetchRequest {
|
||||
let file_request = ObserveRequest {
|
||||
path: "/hello.txt".into(),
|
||||
depth: 0,
|
||||
};
|
||||
let response = exchange(
|
||||
address,
|
||||
encode_fetch_request(&endpoint, &file_request, limits).expect("encode fetch"),
|
||||
encode_observe_request(&endpoint, &file_request, limits).expect("encode observation"),
|
||||
);
|
||||
let file =
|
||||
success(decode_fetch_response(&file_request, &response, limits).expect("decode fetch"));
|
||||
let file = success(
|
||||
decode_observe_response(&file_request, &response, limits).expect("decode observation"),
|
||||
)
|
||||
.object;
|
||||
assert_eq!(file.name, "hello.txt");
|
||||
assert_eq!(file.interfaces.len(), 2);
|
||||
assert!(file.validator.is_some());
|
||||
|
||||
let tree_request = FetchTreeRequest {
|
||||
let tree_request = ObserveRequest {
|
||||
path: "/".into(),
|
||||
depth: 1,
|
||||
limit: 10,
|
||||
};
|
||||
let response = exchange(
|
||||
address,
|
||||
encode_fetch_tree_request(&endpoint, &tree_request, limits).expect("encode tree"),
|
||||
encode_observe_request(&endpoint, &tree_request, limits).expect("encode tree"),
|
||||
);
|
||||
let tree =
|
||||
success(decode_fetch_tree_response(&tree_request, &response, limits).expect("decode tree"));
|
||||
assert!(!tree.truncated);
|
||||
assert_eq!(tree.root.children.len(), 2);
|
||||
success(decode_observe_response(&tree_request, &response, limits).expect("decode tree"));
|
||||
assert_eq!(
|
||||
tree.children
|
||||
.as_ref()
|
||||
.expect("root children observed")
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
|
||||
let read_interface_request = FetchInterfaceRequest {
|
||||
interface: file.interfaces[0].clone(),
|
||||
@@ -93,6 +98,7 @@ fn real_listener_serves_four_core_interactions_batch_and_write() {
|
||||
validator: file.validator.clone(),
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
scope_ref: None,
|
||||
reference: file.interfaces[0].clone(),
|
||||
validator: None,
|
||||
},
|
||||
@@ -118,34 +124,6 @@ fn real_listener_serves_four_core_interactions_batch_and_write() {
|
||||
);
|
||||
assert_eq!(read.result, Value::Bytes(b"world".to_vec()));
|
||||
|
||||
let batch_request = BatchRequest {
|
||||
requests: vec![
|
||||
BatchRequestItem {
|
||||
id: "root".into(),
|
||||
request: BatchRequestKind::Fetch(FetchRequest { path: "/".into() }),
|
||||
},
|
||||
BatchRequestItem {
|
||||
id: "tree".into(),
|
||||
request: BatchRequestKind::FetchTree(FetchTreeRequest {
|
||||
path: "/nested".into(),
|
||||
depth: 1,
|
||||
limit: 4,
|
||||
}),
|
||||
},
|
||||
BatchRequestItem {
|
||||
id: "interface".into(),
|
||||
request: BatchRequestKind::FetchInterface(read_interface_request.clone()),
|
||||
},
|
||||
],
|
||||
};
|
||||
let response = exchange(
|
||||
address,
|
||||
encode_batch_request(&endpoint, &batch_request, limits).expect("encode batch"),
|
||||
);
|
||||
let batch =
|
||||
success(decode_batch_response(&batch_request, &response, limits).expect("decode batch"));
|
||||
assert_batch_successes(&batch);
|
||||
|
||||
let write_interface_request = FetchInterfaceRequest {
|
||||
interface: file.interfaces[1].clone(),
|
||||
};
|
||||
@@ -164,6 +142,7 @@ fn real_listener_serves_four_core_interactions_batch_and_write() {
|
||||
validator: file.validator,
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
scope_ref: None,
|
||||
reference: file.interfaces[1].clone(),
|
||||
validator: None,
|
||||
},
|
||||
@@ -202,11 +181,166 @@ fn real_listener_serves_four_core_interactions_batch_and_write() {
|
||||
.expect("listener exits cleanly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_listener_keeps_route_method_and_malformed_body_boundaries_distinct() {
|
||||
let temporary = tempdir().expect("temporary root");
|
||||
let mut config = ServerConfig::for_root(temporary.path());
|
||||
config.limits.wire = Limits::new(64, 4_096, 64).expect("wire limits");
|
||||
let bound = Arc::new(
|
||||
WipFsServer::new(config)
|
||||
.expect("build server")
|
||||
.bind()
|
||||
.expect("bind"),
|
||||
);
|
||||
let address = bound.local_addr();
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
let worker = {
|
||||
let bound = Arc::clone(&bound);
|
||||
let shutdown = Arc::clone(&shutdown);
|
||||
thread::spawn(move || bound.serve_until(shutdown))
|
||||
};
|
||||
|
||||
for uri in [
|
||||
"/wip/v1/unknown",
|
||||
"/wip/v1/batch",
|
||||
"/wip/v1/observe?unexpected=true",
|
||||
"/wip/v1/observe/extra",
|
||||
] {
|
||||
let response = exchange(address, json_request(http::Method::POST, uri, b"{}"));
|
||||
assert_eq!(response.status(), http::StatusCode::NOT_FOUND);
|
||||
assert!(response.body().is_empty());
|
||||
assert!(!response.headers().contains_key(http::header::CONTENT_TYPE));
|
||||
assert!(!response.headers().contains_key(http::header::ALLOW));
|
||||
}
|
||||
|
||||
let wrong_method = exchange(
|
||||
address,
|
||||
json_request(http::Method::GET, "/wip/v1/observe", b""),
|
||||
);
|
||||
assert_eq!(wrong_method.status(), http::StatusCode::METHOD_NOT_ALLOWED);
|
||||
assert_eq!(wrong_method.headers()[http::header::ALLOW], "POST");
|
||||
assert!(wrong_method.body().is_empty());
|
||||
assert!(
|
||||
!wrong_method
|
||||
.headers()
|
||||
.contains_key(http::header::CONTENT_TYPE)
|
||||
);
|
||||
|
||||
let mut unsupported_coding = json_request(
|
||||
http::Method::POST,
|
||||
"/wip/v1/observe",
|
||||
br#"{"path":"/","depth":0}"#,
|
||||
);
|
||||
unsupported_coding
|
||||
.headers_mut()
|
||||
.insert(http::header::CONTENT_ENCODING, "gzip".parse().unwrap());
|
||||
let unsupported_coding = exchange(address, unsupported_coding);
|
||||
assert_eq!(
|
||||
unsupported_coding.status(),
|
||||
http::StatusCode::UNSUPPORTED_MEDIA_TYPE
|
||||
);
|
||||
assert!(unsupported_coding.body().is_empty());
|
||||
assert!(
|
||||
!unsupported_coding
|
||||
.headers()
|
||||
.contains_key(http::header::CONTENT_TYPE)
|
||||
);
|
||||
|
||||
let malformed = exchange(
|
||||
address,
|
||||
json_request(http::Method::POST, "/wip/v1/observe", b"{"),
|
||||
);
|
||||
assert_eq!(malformed.status(), http::StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
malformed.headers()[http::header::CONTENT_TYPE],
|
||||
JSON_CONTENT_TYPE
|
||||
);
|
||||
assert!(
|
||||
std::str::from_utf8(malformed.body())
|
||||
.expect("UTF-8 error envelope")
|
||||
.contains("\"invalid_request\"")
|
||||
);
|
||||
|
||||
let oversized = exchange(
|
||||
address,
|
||||
json_request(http::Method::POST, "/wip/v1/observe", &[b'x'; 65]),
|
||||
);
|
||||
assert_eq!(oversized.status(), http::StatusCode::PAYLOAD_TOO_LARGE);
|
||||
assert!(oversized.body().is_empty());
|
||||
assert!(!oversized.headers().contains_key(http::header::CONTENT_TYPE));
|
||||
|
||||
let mut header_only = TcpStream::connect(address).expect("connect header-only client");
|
||||
header_only
|
||||
.set_read_timeout(Some(Duration::from_secs(1)))
|
||||
.expect("set header-only timeout");
|
||||
write!(
|
||||
header_only,
|
||||
"POST /wip/v1/observe HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: 65\r\n\r\n"
|
||||
)
|
||||
.expect("write oversized request head");
|
||||
header_only.flush().expect("flush oversized request head");
|
||||
let mut raw = Vec::new();
|
||||
header_only
|
||||
.read_to_end(&mut raw)
|
||||
.expect("read immediate limit response");
|
||||
assert!(raw.starts_with(b"HTTP/1.1 413 "));
|
||||
assert!(raw.ends_with(b"\r\n\r\n"));
|
||||
assert!(
|
||||
!raw.windows(b"resource_limit_exceeded".len())
|
||||
.any(|window| { window == b"resource_limit_exceeded" })
|
||||
);
|
||||
|
||||
shutdown.store(true, Ordering::Release);
|
||||
worker
|
||||
.join()
|
||||
.expect("join listener")
|
||||
.expect("listener exits cleanly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_decoder_limits_are_transport_failures() {
|
||||
let temporary = tempdir().expect("temporary root");
|
||||
let mut config = ServerConfig::for_root(temporary.path());
|
||||
config.limits.wire = Limits::new(4_096, 4_096, 8).expect("wire limits");
|
||||
let server = WipFsServer::new(config).expect("build server");
|
||||
|
||||
let nested = br#"{"path":[[[[[[[[["/"]]]]]]]]],"depth":0}"#;
|
||||
let response = server.handle_http(json_request(http::Method::POST, "/wip/v1/observe", nested));
|
||||
assert_eq!(response.status(), http::StatusCode::PAYLOAD_TOO_LARGE);
|
||||
assert!(response.body().is_empty());
|
||||
assert!(!response.headers().contains_key(http::header::CONTENT_TYPE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logical_observation_limit_remains_protocol_resource_failure() {
|
||||
let temporary = tempdir().expect("temporary root");
|
||||
let mut config = ServerConfig::for_root(temporary.path());
|
||||
config.limits.max_observation_depth = 0;
|
||||
let limits = config.limits.wire;
|
||||
let server = WipFsServer::new(config).expect("build server");
|
||||
let endpoint = Endpoint::parse("http://localhost/wip").expect("endpoint");
|
||||
let request = ObserveRequest {
|
||||
path: "/".into(),
|
||||
depth: 1,
|
||||
};
|
||||
|
||||
let response = server.handle_http(
|
||||
encode_observe_request(&endpoint, &request, limits).expect("encode observation"),
|
||||
);
|
||||
assert_eq!(response.status(), http::StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert!(matches!(
|
||||
decode_observe_response(&request, &response, limits).expect("decode logical limit"),
|
||||
DecodedResponse::ProtocolFailure {
|
||||
error,
|
||||
status_mismatch: None,
|
||||
} if error.code == ProtocolErrorCode::ResourceLimitExceeded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stalled_request_body_times_out_and_releases_the_only_worker() {
|
||||
let temporary = tempdir().expect("temporary root");
|
||||
let mut config = ServerConfig::for_root(temporary.path());
|
||||
config.transport_security = TransportSecurity::DevelopmentHttp;
|
||||
config.limits.max_concurrent_requests = 1;
|
||||
config.limits.request_timeout = Duration::from_millis(200);
|
||||
let limits = config.limits.wire;
|
||||
@@ -230,21 +364,25 @@ fn stalled_request_body_times_out_and_releases_the_only_worker() {
|
||||
.expect("set stalled read timeout");
|
||||
write!(
|
||||
stalled,
|
||||
"POST /wip/v1/fetch HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nAccept: application/json\r\nContent-Length: 100\r\n\r\n{{"
|
||||
"POST /wip/v1/observe HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nAccept: application/json\r\nContent-Length: 100\r\n\r\n{{"
|
||||
)
|
||||
.expect("write partial request");
|
||||
stalled.flush().expect("flush partial request");
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
|
||||
let endpoint = Endpoint::parse(&format!("https://localhost:{}/wip", address.port()))
|
||||
let endpoint = Endpoint::parse(&format!("http://localhost:{}/wip", address.port()))
|
||||
.expect("client endpoint");
|
||||
let fetch = FetchRequest { path: "/".into() };
|
||||
let observe = ObserveRequest {
|
||||
path: "/".into(),
|
||||
depth: 0,
|
||||
};
|
||||
let started = Instant::now();
|
||||
let response = exchange(
|
||||
address,
|
||||
encode_fetch_request(&endpoint, &fetch, limits).expect("encode fetch"),
|
||||
encode_observe_request(&endpoint, &observe, limits).expect("encode observation"),
|
||||
);
|
||||
let _root = success(decode_fetch_response(&fetch, &response, limits).expect("decode fetch"));
|
||||
let _root =
|
||||
success(decode_observe_response(&observe, &response, limits).expect("decode observation"));
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(1),
|
||||
"the valid request remained starved after the intake deadline"
|
||||
@@ -270,44 +408,13 @@ fn stalled_request_body_times_out_and_releases_the_only_worker() {
|
||||
.expect("listener exits cleanly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_keeps_known_malformed_arguments_item_local() {
|
||||
let temporary = tempdir().expect("temporary root");
|
||||
let config = ServerConfig::for_root(temporary.path());
|
||||
let limits = config.limits.wire;
|
||||
let server = WipFsServer::new(config).expect("build server");
|
||||
let endpoint = Endpoint::parse("https://localhost/wip").expect("endpoint");
|
||||
let body = br#"{"requests":[{"id":"ok","method":"fetch","arguments":{"path":"/"}},{"id":"bad","method":"fetch_tree","arguments":{"path":"/","depth":"bad","limit":1}}]}"#;
|
||||
let logical = decode_batch_request(body, limits).expect("decode item-local invalid request");
|
||||
assert!(matches!(
|
||||
logical.requests[1].request,
|
||||
BatchRequestKind::Invalid { .. }
|
||||
));
|
||||
let request = http::Request::builder()
|
||||
.method(http::Method::POST)
|
||||
.uri(endpoint.route(Route::Batch).as_str())
|
||||
.header(http::header::CONTENT_TYPE, JSON_CONTENT_TYPE)
|
||||
.header(http::header::ACCEPT, "application/json")
|
||||
.body(body.to_vec())
|
||||
.expect("build request");
|
||||
let response = server.handle_http(request);
|
||||
let batch = success(
|
||||
decode_batch_response(&logical, &response, limits).expect("decode mixed batch response"),
|
||||
);
|
||||
assert!(matches!(
|
||||
batch.responses[0].outcome,
|
||||
Ok(BatchSuccess::Fetch(_))
|
||||
));
|
||||
assert!(batch.responses[1].outcome.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_argument_decoding_does_not_mask_missing_target() {
|
||||
let temporary = tempdir().expect("temporary root");
|
||||
let config = ServerConfig::for_root(temporary.path());
|
||||
let server = WipFsServer::new(config).expect("build server");
|
||||
let endpoint = Endpoint::parse("https://localhost/wip").expect("endpoint");
|
||||
let body = br#"{"target":{"path":"/missing"},"interface":{"reference":"unknown"},"operation":"missing","arguments":{"bytes":"not-base64"}}"#;
|
||||
let endpoint = Endpoint::parse("http://localhost/wip").expect("endpoint");
|
||||
let body = br#"{"target":{"path":"/missing"},"interface":{"reference":{"scope":"/","name":"unknown"}},"operation":"missing","arguments":{"bytes":"not-base64"}}"#;
|
||||
let request = http::Request::builder()
|
||||
.method(http::Method::POST)
|
||||
.uri(endpoint.route(Route::CallOperation).as_str())
|
||||
@@ -330,14 +437,16 @@ fn backend_invalid_unicode_path_is_not_found_not_internal() {
|
||||
let config = ServerConfig::for_root(temporary.path());
|
||||
let limits = config.limits.wire;
|
||||
let server = WipFsServer::new(config).expect("build server");
|
||||
let endpoint = Endpoint::parse("https://localhost/wip").expect("endpoint");
|
||||
let request = FetchRequest {
|
||||
let endpoint = Endpoint::parse("http://localhost/wip").expect("endpoint");
|
||||
let request = ObserveRequest {
|
||||
path: "/bad\0name".into(),
|
||||
depth: 0,
|
||||
};
|
||||
let response = server
|
||||
.handle_http(encode_fetch_request(&endpoint, &request, limits).expect("encode fetch"));
|
||||
let response = server.handle_http(
|
||||
encode_observe_request(&endpoint, &request, limits).expect("encode observation"),
|
||||
);
|
||||
assert_eq!(response.status(), http::StatusCode::NOT_FOUND);
|
||||
match decode_fetch_response(&request, &response, limits).expect("decode missing response") {
|
||||
match decode_observe_response(&request, &response, limits).expect("decode missing response") {
|
||||
DecodedResponse::ProtocolFailure { error, .. } => {
|
||||
assert_eq!(error.code, ProtocolErrorCode::NotFound);
|
||||
}
|
||||
@@ -346,23 +455,26 @@ fn backend_invalid_unicode_path_is_not_found_not_internal() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_response_size_failure_preserves_non_commit_guarantee() {
|
||||
fn read_response_size_failure_is_transport_local() {
|
||||
let temporary = tempdir().expect("temporary root");
|
||||
std::fs::write(temporary.path().join("large"), vec![b'x'; 2_000]).expect("write file");
|
||||
let mut config = ServerConfig::for_root(temporary.path());
|
||||
config.limits.wire = Limits::new(4_096, 1_024, 64, 64).expect("wire limits");
|
||||
config.limits.wire = Limits::new(4_096, 1_024, 64).expect("wire limits");
|
||||
let limits = config.limits.wire;
|
||||
let server = WipFsServer::new(config).expect("build server");
|
||||
let endpoint = Endpoint::parse("https://localhost/wip").expect("endpoint");
|
||||
let endpoint = Endpoint::parse("http://localhost/wip").expect("endpoint");
|
||||
|
||||
let fetch_request = FetchRequest {
|
||||
let observe_request = ObserveRequest {
|
||||
path: "/large".into(),
|
||||
depth: 0,
|
||||
};
|
||||
let response = server.handle_http(
|
||||
encode_fetch_request(&endpoint, &fetch_request, limits).expect("encode fetch"),
|
||||
encode_observe_request(&endpoint, &observe_request, limits).expect("encode observation"),
|
||||
);
|
||||
let file =
|
||||
success(decode_fetch_response(&fetch_request, &response, limits).expect("decode fetch"));
|
||||
let file = success(
|
||||
decode_observe_response(&observe_request, &response, limits).expect("decode observation"),
|
||||
)
|
||||
.object;
|
||||
let interface_request = FetchInterfaceRequest {
|
||||
interface: file.interfaces[0].clone(),
|
||||
};
|
||||
@@ -380,6 +492,7 @@ fn read_response_size_failure_preserves_non_commit_guarantee() {
|
||||
validator: file.validator,
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
scope_ref: None,
|
||||
reference: file.interfaces[0].clone(),
|
||||
validator: None,
|
||||
},
|
||||
@@ -390,30 +503,15 @@ fn read_response_size_failure_preserves_non_commit_guarantee() {
|
||||
encode_call_operation_request(&endpoint, &call, &interface.descriptor, limits)
|
||||
.expect("encode read call"),
|
||||
);
|
||||
assert_eq!(response.status(), http::StatusCode::PAYLOAD_TOO_LARGE);
|
||||
match decode_call_operation_response(&call, &interface.descriptor, &response, limits)
|
||||
.expect("decode resource failure")
|
||||
{
|
||||
DecodedResponse::ProtocolFailure { error, .. } => {
|
||||
assert_eq!(error.code, ProtocolErrorCode::ResourceLimitExceeded);
|
||||
}
|
||||
DecodedResponse::Success(_) => panic!("oversized read response must fail"),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_batch_successes(batch: &BatchResponse) {
|
||||
assert_eq!(batch.responses.len(), 3);
|
||||
assert_eq!(response.status(), http::StatusCode::INTERNAL_SERVER_ERROR);
|
||||
assert!(response.body().is_empty());
|
||||
assert!(!response.headers().contains_key(http::header::CONTENT_TYPE));
|
||||
assert!(matches!(
|
||||
batch.responses[0].outcome,
|
||||
Ok(BatchSuccess::Fetch(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
batch.responses[1].outcome,
|
||||
Ok(BatchSuccess::FetchTree(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
batch.responses[2].outcome,
|
||||
Ok(BatchSuccess::FetchInterface(_))
|
||||
decode_call_operation_response(&call, &interface.descriptor, &response, limits)
|
||||
.expect_err("oversized representation is not a protocol failure"),
|
||||
ClientResponseError::TransportBinding(failure)
|
||||
if failure.status == http::StatusCode::INTERNAL_SERVER_ERROR
|
||||
&& failure.kind == TransportFailureKind::HttpStatus
|
||||
));
|
||||
}
|
||||
|
||||
@@ -424,6 +522,16 @@ fn success<T>(response: DecodedResponse<T>) -> T {
|
||||
}
|
||||
}
|
||||
|
||||
fn json_request(method: http::Method, uri: &str, body: &[u8]) -> http::Request<Vec<u8>> {
|
||||
http::Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header(http::header::CONTENT_TYPE, JSON_CONTENT_TYPE)
|
||||
.header(http::header::ACCEPT, "application/json")
|
||||
.body(body.to_vec())
|
||||
.expect("build JSON request")
|
||||
}
|
||||
|
||||
fn exchange(address: SocketAddr, request: http::Request<Vec<u8>>) -> http::Response<Vec<u8>> {
|
||||
let mut stream = TcpStream::connect(address).expect("connect listener");
|
||||
stream
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,17 @@
|
||||
[package]
|
||||
name = "wip-host"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
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]
|
||||
wip-interface = { path = "../wip-interface" }
|
||||
wip-protocol = { path = "../wip-protocol" }
|
||||
wip-interface.workspace = true
|
||||
wip-protocol.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
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.
|
||||
+249
-24
@@ -49,8 +49,8 @@ pub use wip_protocol::{
|
||||
Target,
|
||||
};
|
||||
use wip_protocol::{
|
||||
FetchInterfaceRequest, FetchInterfaceResponse, InterfaceDescriptor, Object, ProtocolError,
|
||||
ProtocolErrorCode, ValidationError, Value,
|
||||
FetchInterfaceRequest, FetchInterfaceResponse, InterfaceDescriptor, InterfaceReference, Object,
|
||||
ProtocolError, ProtocolErrorCode, ValidationError, Value,
|
||||
};
|
||||
|
||||
/// Opaque bytes used as a conditional execution precondition.
|
||||
@@ -192,11 +192,22 @@ impl ExecutionContext {
|
||||
pub struct InterfaceRef(String);
|
||||
|
||||
impl InterfaceRef {
|
||||
/// Returns the opaque reference for a protocol transport.
|
||||
/// Returns the legacy opaque registration reference.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&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.
|
||||
@@ -211,6 +222,17 @@ pub struct 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.
|
||||
///
|
||||
/// A Host-side response construction failure is returned as `Internal`; it is
|
||||
@@ -218,13 +240,14 @@ impl InterfaceObservation {
|
||||
/// local validation error.
|
||||
pub fn protocol_response(&self) -> Result<FetchInterfaceResponse, ProtocolError> {
|
||||
let response = FetchInterfaceResponse {
|
||||
interface: self.reference.0.clone(),
|
||||
interface: self.reference.protocol_reference(),
|
||||
descriptor: self.descriptor.clone(),
|
||||
scope_ref: None,
|
||||
validator: self.validator.as_ref().map(|validator| validator.0.clone()),
|
||||
};
|
||||
response
|
||||
.validate_for(&FetchInterfaceRequest {
|
||||
interface: self.reference.0.clone(),
|
||||
interface: self.reference.protocol_reference(),
|
||||
})
|
||||
.map_err(|_| ProtocolError {
|
||||
code: ProtocolErrorCode::Internal,
|
||||
@@ -343,27 +366,39 @@ 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]
|
||||
pub fn resolve(&self, reference: &str) -> Option<InterfaceRef> {
|
||||
let reference = InterfaceRef(reference.to_owned());
|
||||
pub fn resolve(&self, reference: &InterfaceReference) -> Option<InterfaceRef> {
|
||||
if reference.scope != "/" {
|
||||
return None;
|
||||
}
|
||||
let reference = InterfaceRef(reference.name.clone());
|
||||
self.artifacts.contains_key(&reference).then_some(reference)
|
||||
}
|
||||
|
||||
/// Observes a registered interface by its host-side reference.
|
||||
#[must_use]
|
||||
pub fn observe(&self, reference: &InterfaceRef) -> Option<InterfaceObservation> {
|
||||
pub fn observe_object(&self, reference: &InterfaceRef) -> Option<InterfaceObservation> {
|
||||
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`,
|
||||
/// not object membership errors or authorization failures.
|
||||
pub fn fetch_interface(&self, reference: &str) -> Result<InterfaceObservation, ProtocolError> {
|
||||
let reference = InterfaceRef(reference.to_owned());
|
||||
self.artifacts
|
||||
.get(&reference)
|
||||
/// Malformed references map to `InvalidRequest`. Unknown and currently
|
||||
/// unpublished references (including non-root scopes) map to `InterfaceNotFound`.
|
||||
pub fn fetch_interface(
|
||||
&self,
|
||||
reference: &InterfaceReference,
|
||||
) -> 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)
|
||||
.ok_or_else(|| ProtocolError {
|
||||
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>;
|
||||
|
||||
struct BoundInterface<T> {
|
||||
@@ -502,6 +591,46 @@ impl<T: WipObject> PublishedObject<T> {
|
||||
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.
|
||||
#[must_use]
|
||||
pub fn implementation(&self) -> &T {
|
||||
@@ -520,7 +649,7 @@ impl<T: WipObject> PublishedObject<T> {
|
||||
interfaces: self
|
||||
.interfaces
|
||||
.iter()
|
||||
.map(|bound| bound.artifact.reference.0.clone())
|
||||
.map(|bound| bound.artifact.reference.protocol_reference())
|
||||
.collect(),
|
||||
r#ref: self.implementation.reference().map(str::to_owned),
|
||||
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
|
||||
.interfaces
|
||||
.iter()
|
||||
.find(|bound| bound.artifact.reference.as_str() == interface.reference.as_str())
|
||||
.find(|bound| bound.artifact.reference.protocol_reference() == interface.reference)
|
||||
else {
|
||||
return pre_dispatch_failure(
|
||||
CallErrorKind::InterfaceMismatch,
|
||||
@@ -782,6 +918,15 @@ pub enum 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.
|
||||
///
|
||||
/// Successful attempts and pre-dispatch failures are returned unchanged.
|
||||
@@ -863,13 +1008,13 @@ pub trait ObjectProvider {
|
||||
true
|
||||
}
|
||||
|
||||
/// Fetches the canonical object projection currently published at `path`.
|
||||
fn fetch(&self, path: &str) -> Result<Object, CallError> {
|
||||
self.fetch_with_context(&ExecutionContext::default(), path)
|
||||
/// Observes the canonical object projection currently published at `path`.
|
||||
fn observe_object(&self, path: &str) -> Result<Object, CallError> {
|
||||
self.observe_object_with_context(&ExecutionContext::default(), path)
|
||||
}
|
||||
|
||||
/// Fetches an object while propagating runtime execution controls.
|
||||
fn fetch_with_context(
|
||||
/// Observes an object while propagating runtime execution controls.
|
||||
fn observe_object_with_context(
|
||||
&self,
|
||||
context: &ExecutionContext,
|
||||
path: &str,
|
||||
@@ -882,7 +1027,7 @@ pub trait ObjectProvider {
|
||||
.map_err(|_| {
|
||||
CallError::new(
|
||||
CallErrorKind::Internal,
|
||||
"provider panicked while fetching an object",
|
||||
"provider panicked while observing an object",
|
||||
)
|
||||
})?;
|
||||
let object = match resolved {
|
||||
@@ -925,6 +1070,11 @@ pub trait ObjectProvider {
|
||||
) -> Result<CallResult, CallError> {
|
||||
wip_protocol::validate_path(&request.target.path)
|
||||
.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 execution = catch_unwind(AssertUnwindSafe(|| {
|
||||
self.execute_operation(context, &path, |publication| {
|
||||
@@ -974,6 +1124,10 @@ pub trait ObjectProvider {
|
||||
) -> Result<CallResult, CallError> {
|
||||
wip_protocol::validate_path(&target.path)
|
||||
.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 execution = catch_unwind(AssertUnwindSafe(|| {
|
||||
self.execute_operation(context, &path, |publication| {
|
||||
@@ -1016,11 +1170,18 @@ pub trait ObjectProvider {
|
||||
pub enum PublicationError {
|
||||
/// The same opaque interface reference was exposed more than once.
|
||||
DuplicateInterface(InterfaceRef),
|
||||
/// Projection attempted to add an interface without an implementation.
|
||||
UnexposedInterface(InterfaceRef),
|
||||
}
|
||||
|
||||
impl Display for PublicationError {
|
||||
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::UnexposedInterface(reference) => write!(
|
||||
formatter,
|
||||
"interface `{}` has no implementation",
|
||||
reference.as_str()
|
||||
),
|
||||
Self::DuplicateInterface(reference) => write!(
|
||||
formatter,
|
||||
"interface `{}` is already exposed by this object",
|
||||
@@ -1032,6 +1193,70 @@ impl Display 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.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CallErrorKind {
|
||||
|
||||
+194
-19
@@ -14,6 +14,69 @@ use wip_interface::{
|
||||
};
|
||||
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]
|
||||
pub trait Reader {
|
||||
/// Returns the reader-qualified label.
|
||||
@@ -422,7 +485,8 @@ fn request(
|
||||
validator: object_validator.map(<[u8]>::to_vec),
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: reference.as_str().to_owned(),
|
||||
reference: reference.protocol_reference(),
|
||||
scope_ref: None,
|
||||
validator: interface_validator.map(<[u8]>::to_vec),
|
||||
},
|
||||
operation: operation.into(),
|
||||
@@ -441,17 +505,22 @@ fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor()
|
||||
let reference = provider.reader.reference();
|
||||
|
||||
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.descriptor, ReaderDefinition::descriptor());
|
||||
assert_eq!(observation.validator, Some(Validator::new(b"schema-v1")));
|
||||
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.validator.as_deref(), Some(b"schema-v1".as_slice()));
|
||||
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();
|
||||
invalid_observation
|
||||
.descriptor
|
||||
@@ -460,14 +529,19 @@ fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor()
|
||||
let invalid_response = invalid_observation.protocol_response().unwrap_err();
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_object_projection_supports_optional_metadata_and_ordered_sets() {
|
||||
let (_, provider, _) = registry_and_provider();
|
||||
let object = provider.fetch("/document").unwrap();
|
||||
let object = provider.observe_object("/document").unwrap();
|
||||
|
||||
assert_eq!(object.name, "document");
|
||||
assert_eq!(object.description.as_deref(), Some("public document"));
|
||||
@@ -476,14 +550,14 @@ fn canonical_object_projection_supports_optional_metadata_and_ordered_sets() {
|
||||
assert_eq!(
|
||||
object.interfaces,
|
||||
vec![
|
||||
provider.writer.reference().as_str().to_owned(),
|
||||
provider.reader.reference().as_str().to_owned(),
|
||||
provider.writer.reference().protocol_reference(),
|
||||
provider.reader.reference().protocol_reference(),
|
||||
]
|
||||
);
|
||||
assert!(!format!("{object:?}").contains("secret-handle"));
|
||||
assert!(!format!("{object:?}").contains("/document"));
|
||||
|
||||
let empty = provider.fetch("/empty").unwrap();
|
||||
let empty = provider.observe_object("/empty").unwrap();
|
||||
assert!(empty.interfaces.is_empty());
|
||||
assert_eq!(empty.r#ref, None);
|
||||
assert_eq!(empty.validator, None);
|
||||
@@ -518,13 +592,13 @@ fn duplicate_exposure_is_rejected_and_private_state_is_not_projected() {
|
||||
fn paths_are_resolution_context_not_object_identity() {
|
||||
let (_, provider, _) = registry_and_provider();
|
||||
|
||||
let left = provider.fetch("/left/document").unwrap();
|
||||
let right = provider.fetch("/right/document").unwrap();
|
||||
let left = provider.observe_object("/left/document").unwrap();
|
||||
let right = provider.observe_object("/right/document").unwrap();
|
||||
assert_eq!(left, right);
|
||||
assert_eq!(left.r#ref.as_deref(), Some("shared-ref"));
|
||||
|
||||
let first = provider.fetch("/switch").unwrap();
|
||||
let second = provider.fetch("/switch").unwrap();
|
||||
let first = provider.observe_object("/switch").unwrap();
|
||||
let second = provider.observe_object("/switch").unwrap();
|
||||
assert_ne!(first.r#ref, second.r#ref);
|
||||
assert_ne!(first.validator, second.validator);
|
||||
}
|
||||
@@ -603,7 +677,7 @@ fn call_failures_follow_canonical_precedence_without_execution() {
|
||||
}
|
||||
assert_eq!(provider.resolutions.get(), resolutions);
|
||||
assert_failure(
|
||||
provider.fetch("/invalid/").unwrap_err(),
|
||||
provider.observe_object("/invalid/").unwrap_err(),
|
||||
CallErrorKind::InvalidRequest,
|
||||
ProtocolErrorCode::InvalidRequest,
|
||||
);
|
||||
@@ -758,7 +832,8 @@ fn descriptor_guided_decoder_runs_inside_canonical_resolution_boundary() {
|
||||
validator: Some(b"stale".to_vec()),
|
||||
},
|
||||
InterfaceTarget {
|
||||
reference: hidden.reference().as_str().into(),
|
||||
reference: hidden.reference().protocol_reference(),
|
||||
scope_ref: None,
|
||||
validator: None,
|
||||
},
|
||||
"missing".into(),
|
||||
@@ -776,7 +851,8 @@ fn descriptor_guided_decoder_runs_inside_canonical_resolution_boundary() {
|
||||
validator: Some(b"stale".to_vec()),
|
||||
},
|
||||
InterfaceTarget {
|
||||
reference: hidden.reference().as_str().into(),
|
||||
reference: hidden.reference().protocol_reference(),
|
||||
scope_ref: None,
|
||||
validator: None,
|
||||
},
|
||||
"hidden".into(),
|
||||
@@ -794,7 +870,8 @@ fn descriptor_guided_decoder_runs_inside_canonical_resolution_boundary() {
|
||||
validator: Some(b"object-v1".to_vec()),
|
||||
},
|
||||
InterfaceTarget {
|
||||
reference: hidden.reference().as_str().into(),
|
||||
reference: hidden.reference().protocol_reference(),
|
||||
scope_ref: None,
|
||||
validator: None,
|
||||
},
|
||||
"hidden".into(),
|
||||
@@ -812,7 +889,8 @@ fn descriptor_guided_decoder_runs_inside_canonical_resolution_boundary() {
|
||||
validator: Some(b"object-v1".to_vec()),
|
||||
},
|
||||
InterfaceTarget {
|
||||
reference: provider.reader.reference().as_str().into(),
|
||||
reference: provider.reader.reference().protocol_reference(),
|
||||
scope_ref: None,
|
||||
validator: Some(b"schema-v1".to_vec()),
|
||||
},
|
||||
"add".into(),
|
||||
@@ -849,7 +927,7 @@ fn invalid_host_responses_and_uncertain_operation_failures_use_canonical_codes()
|
||||
let (_, provider, _) = registry_and_provider();
|
||||
|
||||
assert_failure(
|
||||
provider.fetch("/invalid-object").unwrap_err(),
|
||||
provider.observe_object("/invalid-object").unwrap_err(),
|
||||
CallErrorKind::Internal,
|
||||
ProtocolErrorCode::Internal,
|
||||
);
|
||||
@@ -1066,3 +1144,100 @@ fn execution_context_is_propagated_and_preflight_prevents_invocation() {
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "wip-http"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Framework-independent standard WIP over HTTP v1 binding"
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
readme = "README.md"
|
||||
documentation = "https://docs.rs/wip-http"
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
http = "1"
|
||||
serde = "1"
|
||||
serde_json = "1"
|
||||
url = "2"
|
||||
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.
|
||||
@@ -1,18 +1,18 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{self, Display, Formatter};
|
||||
|
||||
use http::header::{ACCEPT, CONTENT_TYPE};
|
||||
use http::header::{ACCEPT, CONTENT_ENCODING, CONTENT_TYPE};
|
||||
use http::{HeaderMap, Method, Request, Response, StatusCode};
|
||||
use wip_protocol::{
|
||||
CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, FetchInterfaceResponse,
|
||||
FetchRequest, FetchResponse, FetchTreeRequest, FetchTreeResponse, INTERFACE_FORMAT_V1,
|
||||
InterfaceDescriptor, InterfaceTarget, ProtocolError, ProtocolInteraction, Target,
|
||||
INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceTarget, ObserveRequest, ObserveResponse,
|
||||
ProtocolError, ProtocolInteraction, Target,
|
||||
};
|
||||
|
||||
use crate::response::{self, DecodedResponse};
|
||||
use crate::{
|
||||
BodyKind, ClientResponseError, CodecError, Endpoint, InvalidResponseKind, JSON_CONTENT_TYPE,
|
||||
Limits, ResourceLimitKind, Route, json,
|
||||
Limits, Route, json,
|
||||
};
|
||||
|
||||
fn request(
|
||||
@@ -36,6 +36,9 @@ fn response(status: StatusCode, body: Vec<u8>) -> Result<Response<Vec<u8>>, http
|
||||
}
|
||||
|
||||
fn require_response_content_type<B>(response: &Response<B>) -> Result<(), ClientResponseError> {
|
||||
if !has_supported_content_coding(response.headers()) {
|
||||
return Err(response::unsupported_content_coding(response.status()));
|
||||
}
|
||||
let valid = response
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
@@ -181,9 +184,11 @@ fn accepts_json(headers: &HeaderMap) -> bool {
|
||||
/// A failure while validating inbound HTTP request metadata.
|
||||
#[derive(Debug)]
|
||||
pub enum RequestMetadataError {
|
||||
/// The method or route does not match the selected WIP interaction.
|
||||
InvalidRequest(CodecError),
|
||||
/// The request body does not use UTF-8 `application/json`.
|
||||
/// The request URI does not exactly identify the selected standard route.
|
||||
NotFound,
|
||||
/// A known standard route was requested with a method other than `POST`.
|
||||
MethodNotAllowed,
|
||||
/// The request body does not use UTF-8 `application/json` or identity coding.
|
||||
UnsupportedMediaType,
|
||||
/// The request's `Accept` metadata excludes `application/json`.
|
||||
NotAcceptable,
|
||||
@@ -194,32 +199,47 @@ impl RequestMetadataError {
|
||||
#[must_use]
|
||||
pub const fn status(&self) -> StatusCode {
|
||||
match self {
|
||||
Self::InvalidRequest(_) => StatusCode::BAD_REQUEST,
|
||||
Self::NotFound => StatusCode::NOT_FOUND,
|
||||
Self::MethodNotAllowed => StatusCode::METHOD_NOT_ALLOWED,
|
||||
Self::UnsupportedMediaType => StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
||||
Self::NotAcceptable => StatusCode::NOT_ACCEPTABLE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the value for an `Allow` response header, when required.
|
||||
#[must_use]
|
||||
pub const fn allow(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::MethodNotAllowed => Some("POST"),
|
||||
Self::NotFound | Self::UnsupportedMediaType | Self::NotAcceptable => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for RequestMetadataError {
|
||||
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidRequest(error) => error.fmt(formatter),
|
||||
Self::NotFound => formatter.write_str("request URI is not a standard WIP route"),
|
||||
Self::MethodNotAllowed => formatter.write_str("standard WIP routes require POST"),
|
||||
Self::UnsupportedMediaType => {
|
||||
formatter.write_str("expected UTF-8 application/json Content-Type")
|
||||
formatter.write_str("expected identity-coded UTF-8 application/json Content-Type")
|
||||
}
|
||||
Self::NotAcceptable => formatter.write_str("request does not accept application/json"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for RequestMetadataError {
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||
match self {
|
||||
Self::InvalidRequest(error) => Some(error),
|
||||
Self::UnsupportedMediaType | Self::NotAcceptable => None,
|
||||
}
|
||||
}
|
||||
impl Error for RequestMetadataError {}
|
||||
|
||||
pub(crate) fn has_supported_content_coding(headers: &HeaderMap) -> bool {
|
||||
headers.get_all(CONTENT_ENCODING).iter().all(|value| {
|
||||
value.to_str().is_ok_and(|value| {
|
||||
let mut codings = value.split(',').map(str::trim);
|
||||
let first = codings.next();
|
||||
first.is_some_and(|coding| coding.eq_ignore_ascii_case("identity"))
|
||||
&& codings.all(|coding| coding.eq_ignore_ascii_case("identity"))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Validates mandatory HTTP metadata for an inbound WIP request.
|
||||
@@ -233,29 +253,19 @@ pub fn validate_http_request<B>(
|
||||
route: Route,
|
||||
value: &Request<B>,
|
||||
) -> Result<(), RequestMetadataError> {
|
||||
if value.method() != Method::POST {
|
||||
return Err(RequestMetadataError::InvalidRequest(
|
||||
CodecError::InvalidField {
|
||||
field: "method".into(),
|
||||
reason: "standard WIP routes require POST".into(),
|
||||
},
|
||||
));
|
||||
}
|
||||
let expected = endpoint.route(route);
|
||||
if value.uri().path() != expected.path() || value.uri().query().is_some() {
|
||||
return Err(RequestMetadataError::InvalidRequest(
|
||||
CodecError::InvalidField {
|
||||
field: "route".into(),
|
||||
reason: "request URI does not match the selected standard route".into(),
|
||||
},
|
||||
));
|
||||
return Err(RequestMetadataError::NotFound);
|
||||
}
|
||||
if value.method() != Method::POST {
|
||||
return Err(RequestMetadataError::MethodNotAllowed);
|
||||
}
|
||||
let valid_content_type = value
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(is_json_content_type);
|
||||
if !valid_content_type {
|
||||
if !valid_content_type || !has_supported_content_coding(value.headers()) {
|
||||
return Err(RequestMetadataError::UnsupportedMediaType);
|
||||
}
|
||||
if !accepts_json(value.headers()) {
|
||||
@@ -264,57 +274,36 @@ pub fn validate_http_request<B>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Encodes a `fetch` HTTP request.
|
||||
pub fn encode_fetch_request(
|
||||
/// Encodes an `observe` HTTP request.
|
||||
pub fn encode_observe_request(
|
||||
endpoint: &Endpoint,
|
||||
value: &FetchRequest,
|
||||
value: &ObserveRequest,
|
||||
limits: Limits,
|
||||
) -> Result<Request<Vec<u8>>, EncodeHttpError> {
|
||||
value.validate().map_err(CodecError::from)?;
|
||||
let body = json::serialize(
|
||||
&json::fetch_request_to_json(value),
|
||||
&json::observe_request_to_json(value),
|
||||
BodyKind::Request,
|
||||
limits,
|
||||
)?;
|
||||
Ok(request(endpoint, Route::Fetch, body)?)
|
||||
Ok(request(endpoint, Route::Observe, body)?)
|
||||
}
|
||||
|
||||
/// Decodes a `fetch` request body.
|
||||
pub fn decode_fetch_request(body: &[u8], limits: Limits) -> Result<FetchRequest, CodecError> {
|
||||
/// Decodes an `observe` request body.
|
||||
pub fn decode_observe_request(body: &[u8], limits: Limits) -> Result<ObserveRequest, CodecError> {
|
||||
let value = json::parse(body, BodyKind::Request, limits)?;
|
||||
json::fetch_request_from_json(&value)
|
||||
}
|
||||
|
||||
/// Encodes a `fetch_tree` HTTP request.
|
||||
pub fn encode_fetch_tree_request(
|
||||
endpoint: &Endpoint,
|
||||
value: &FetchTreeRequest,
|
||||
limits: Limits,
|
||||
) -> Result<Request<Vec<u8>>, EncodeHttpError> {
|
||||
value.validate().map_err(CodecError::from)?;
|
||||
let body = json::serialize(
|
||||
&json::fetch_tree_request_to_json(value),
|
||||
BodyKind::Request,
|
||||
limits,
|
||||
)?;
|
||||
Ok(request(endpoint, Route::FetchTree, body)?)
|
||||
}
|
||||
|
||||
/// Decodes a `fetch_tree` request body.
|
||||
pub fn decode_fetch_tree_request(
|
||||
body: &[u8],
|
||||
limits: Limits,
|
||||
) -> Result<FetchTreeRequest, CodecError> {
|
||||
let value = json::parse(body, BodyKind::Request, limits)?;
|
||||
json::fetch_tree_request_from_json(&value)
|
||||
json::observe_request_from_json(&value)
|
||||
}
|
||||
|
||||
/// Encodes a `fetch_interface` HTTP request.
|
||||
///
|
||||
/// Validates reference shape without requiring a target Object path.
|
||||
pub fn encode_fetch_interface_request(
|
||||
endpoint: &Endpoint,
|
||||
value: &FetchInterfaceRequest,
|
||||
limits: Limits,
|
||||
) -> Result<Request<Vec<u8>>, EncodeHttpError> {
|
||||
value.interface.validate().map_err(CodecError::from)?;
|
||||
let body = json::serialize(
|
||||
&json::fetch_interface_request_to_json(value),
|
||||
BodyKind::Request,
|
||||
@@ -372,6 +361,10 @@ impl CallOperationMetadata {
|
||||
}
|
||||
|
||||
/// 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(
|
||||
body: &[u8],
|
||||
limits: Limits,
|
||||
@@ -407,72 +400,39 @@ pub fn decode_call_operation_request(
|
||||
json::call_request_from_json(&value, descriptor)
|
||||
}
|
||||
|
||||
/// Encodes a successful `fetch` response as a direct body with HTTP 200.
|
||||
pub fn encode_fetch_response(
|
||||
request_value: &FetchRequest,
|
||||
value: &FetchResponse,
|
||||
/// Encodes a successful `observe` response as a direct body with HTTP 200.
|
||||
pub fn encode_observe_response(
|
||||
request_value: &ObserveRequest,
|
||||
value: &ObserveResponse,
|
||||
limits: Limits,
|
||||
) -> Result<Response<Vec<u8>>, EncodeHttpError> {
|
||||
request_value
|
||||
.validate_response(value)
|
||||
.map_err(CodecError::from)?;
|
||||
let body = json::serialize(&json::object_to_json(value), BodyKind::Response, limits)?;
|
||||
Ok(response(StatusCode::OK, body)?)
|
||||
}
|
||||
|
||||
/// Decodes and classifies a `fetch` client response.
|
||||
pub fn decode_fetch_response<B: AsRef<[u8]>>(
|
||||
request_value: &FetchRequest,
|
||||
value: &Response<B>,
|
||||
limits: Limits,
|
||||
) -> Result<DecodedResponse<FetchResponse>, ClientResponseError> {
|
||||
require_response_content_type(value)?;
|
||||
response::decode(
|
||||
value.status(),
|
||||
value.body().as_ref(),
|
||||
ProtocolInteraction::Fetch,
|
||||
limits,
|
||||
|json| {
|
||||
let object = json::object_from_json(json, "response")?;
|
||||
request_value.validate_response(&object)?;
|
||||
Ok(object)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Encodes a successful `fetch_tree` response as a direct body with HTTP 200.
|
||||
pub fn encode_fetch_tree_response(
|
||||
request_value: &FetchTreeRequest,
|
||||
value: &FetchTreeResponse,
|
||||
limits: Limits,
|
||||
) -> Result<Response<Vec<u8>>, EncodeHttpError> {
|
||||
value
|
||||
.validate_for(request_value)
|
||||
.map_err(CodecError::from)?;
|
||||
let body = json::serialize(
|
||||
&json::fetch_tree_response_to_json(value),
|
||||
&json::observe_response_to_json(value),
|
||||
BodyKind::Response,
|
||||
limits,
|
||||
)?;
|
||||
Ok(response(StatusCode::OK, body)?)
|
||||
}
|
||||
|
||||
/// Decodes and classifies a `fetch_tree` client response.
|
||||
pub fn decode_fetch_tree_response<B: AsRef<[u8]>>(
|
||||
request_value: &FetchTreeRequest,
|
||||
/// Decodes and classifies an `observe` client response.
|
||||
pub fn decode_observe_response<B: AsRef<[u8]>>(
|
||||
request_value: &ObserveRequest,
|
||||
value: &Response<B>,
|
||||
limits: Limits,
|
||||
) -> Result<DecodedResponse<FetchTreeResponse>, ClientResponseError> {
|
||||
) -> Result<DecodedResponse<ObserveResponse>, ClientResponseError> {
|
||||
require_response_content_type(value)?;
|
||||
response::decode(
|
||||
value.status(),
|
||||
value.body().as_ref(),
|
||||
ProtocolInteraction::FetchTree,
|
||||
ProtocolInteraction::Observe,
|
||||
limits,
|
||||
|json| {
|
||||
let tree = json::fetch_tree_response_from_json(json)?;
|
||||
tree.validate_for(request_value)?;
|
||||
Ok(tree)
|
||||
let observation = json::observe_response_from_json(json)?;
|
||||
request_value.validate_response(&observation)?;
|
||||
Ok(observation)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -583,9 +543,8 @@ fn encode_protocol_error_with_status(
|
||||
/// The code must be allowed for `interaction`. This API accepts only
|
||||
/// [`ProtocolError`], so client-local `InvalidResponse` and
|
||||
/// `UnsupportedDescriptorFormat` classifications cannot be emitted as Host
|
||||
/// error envelopes. Resource-limit errors use the logical-limit status; callers
|
||||
/// that know the concrete limit class should use
|
||||
/// [`encode_resource_limit_error_response`].
|
||||
/// error envelopes. Logical `resource_limit_exceeded` failures use HTTP 422;
|
||||
/// representation and deployment limits are not protocol errors.
|
||||
pub fn encode_protocol_error_response(
|
||||
interaction: ProtocolInteraction,
|
||||
error: &ProtocolError,
|
||||
@@ -599,28 +558,6 @@ pub fn encode_protocol_error_response(
|
||||
)
|
||||
}
|
||||
|
||||
/// Encodes a `resource_limit_exceeded` envelope with its class-specific status.
|
||||
pub fn encode_resource_limit_error_response(
|
||||
interaction: ProtocolInteraction,
|
||||
error: &ProtocolError,
|
||||
kind: ResourceLimitKind,
|
||||
limits: Limits,
|
||||
) -> Result<Response<Vec<u8>>, EncodeHttpError> {
|
||||
if error.code != wip_protocol::ProtocolErrorCode::ResourceLimitExceeded {
|
||||
return Err(CodecError::InvalidField {
|
||||
field: "error.code".into(),
|
||||
reason: "resource-limit encoder requires resource_limit_exceeded".into(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
encode_protocol_error_with_status(
|
||||
interaction,
|
||||
error,
|
||||
response::status_for_resource_limit(kind),
|
||||
limits,
|
||||
)
|
||||
}
|
||||
|
||||
/// Failure while building an outbound framework-neutral HTTP message.
|
||||
#[derive(Debug)]
|
||||
pub enum EncodeHttpError {
|
||||
@@ -6,19 +6,15 @@ use url::Url;
|
||||
/// The media type emitted for every WIP request and every response with a body.
|
||||
pub const JSON_CONTENT_TYPE: &str = "application/json; charset=utf-8";
|
||||
|
||||
/// A fixed standard WIP over HTTPS v1 route.
|
||||
/// A fixed standard WIP over HTTP v1 route.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Route {
|
||||
/// Fetch one object.
|
||||
Fetch,
|
||||
/// Fetch a bounded object tree.
|
||||
FetchTree,
|
||||
/// Observe one object and its indexable descendants.
|
||||
Observe,
|
||||
/// Fetch an interface descriptor.
|
||||
FetchInterface,
|
||||
/// Call one operation.
|
||||
CallOperation,
|
||||
/// Package independent retrieval interactions.
|
||||
Batch,
|
||||
}
|
||||
|
||||
impl Route {
|
||||
@@ -26,31 +22,27 @@ impl Route {
|
||||
#[must_use]
|
||||
pub const fn relative_reference(self) -> &'static str {
|
||||
match self {
|
||||
Self::Fetch => "v1/fetch",
|
||||
Self::FetchTree => "v1/fetch_tree",
|
||||
Self::Observe => "v1/observe",
|
||||
Self::FetchInterface => "v1/fetch_interface",
|
||||
Self::CallOperation => "v1/call_operation",
|
||||
Self::Batch => "v1/batch",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A canonical absolute HTTPS base URL whose path always ends in `/`.
|
||||
/// A canonical absolute hierarchical base URL whose path always ends in `/`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Endpoint(Url);
|
||||
|
||||
impl Endpoint {
|
||||
/// Parses and canonicalizes an endpoint.
|
||||
///
|
||||
/// The input must be an absolute HTTPS URL without a query or fragment. Its
|
||||
/// path is an endpoint prefix, not a Worldspace path. A trailing slash is
|
||||
/// appended when absent so RFC 3986 relative-reference resolution preserves
|
||||
/// the final prefix segment.
|
||||
/// The input must be an absolute hierarchical URL with a host and without a
|
||||
/// query or fragment. Its path is an endpoint prefix, not a Worldspace path.
|
||||
/// A trailing slash is appended when absent so RFC 3986 relative-reference
|
||||
/// resolution preserves the final prefix segment. The supplied scheme is
|
||||
/// preserved; scheme and transport security are deployment concerns.
|
||||
pub fn parse(input: &str) -> Result<Self, EndpointError> {
|
||||
let mut url = Url::parse(input).map_err(EndpointError::InvalidUrl)?;
|
||||
if url.scheme() != "https" {
|
||||
return Err(EndpointError::NotHttps);
|
||||
}
|
||||
if url.cannot_be_a_base() || url.host_str().is_none() {
|
||||
return Err(EndpointError::NotAbsoluteBase);
|
||||
}
|
||||
@@ -87,13 +79,7 @@ impl Endpoint {
|
||||
/// are not route components and therefore never match.
|
||||
#[must_use]
|
||||
pub fn recognize(&self, candidate: &Url) -> Option<Route> {
|
||||
const ROUTES: [Route; 5] = [
|
||||
Route::Fetch,
|
||||
Route::FetchTree,
|
||||
Route::FetchInterface,
|
||||
Route::CallOperation,
|
||||
Route::Batch,
|
||||
];
|
||||
const ROUTES: [Route; 3] = [Route::Observe, Route::FetchInterface, Route::CallOperation];
|
||||
if candidate.query().is_some() || candidate.fragment().is_some() {
|
||||
return None;
|
||||
}
|
||||
@@ -117,13 +103,11 @@ impl TryFrom<&str> for Endpoint {
|
||||
}
|
||||
}
|
||||
|
||||
/// Why an endpoint is not a valid standard WIP HTTPS base.
|
||||
/// Why an endpoint is not a valid standard WIP over HTTP base.
|
||||
#[derive(Debug)]
|
||||
pub enum EndpointError {
|
||||
/// URL parsing failed.
|
||||
InvalidUrl(url::ParseError),
|
||||
/// The URL scheme is not `https`.
|
||||
NotHttps,
|
||||
/// The URL is not a hierarchical absolute URL with an authority.
|
||||
NotAbsoluteBase,
|
||||
/// A query component was supplied.
|
||||
@@ -136,7 +120,6 @@ impl Display for EndpointError {
|
||||
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidUrl(error) => write!(formatter, "invalid endpoint URL: {error}"),
|
||||
Self::NotHttps => formatter.write_str("WIP endpoint must use HTTPS"),
|
||||
Self::NotAbsoluteBase => {
|
||||
formatter.write_str("WIP endpoint must be an absolute base URL")
|
||||
}
|
||||
@@ -10,8 +10,6 @@ pub struct Limits {
|
||||
pub(crate) max_response_bytes: usize,
|
||||
/// Largest accepted JSON container nesting depth.
|
||||
pub(crate) max_nesting: usize,
|
||||
/// Largest accepted number of batch items.
|
||||
pub(crate) max_batch_items: usize,
|
||||
}
|
||||
|
||||
impl Limits {
|
||||
@@ -20,7 +18,6 @@ impl Limits {
|
||||
max_request_bytes: usize,
|
||||
max_response_bytes: usize,
|
||||
max_nesting: usize,
|
||||
max_batch_items: usize,
|
||||
) -> Result<Self, LimitConfigurationError> {
|
||||
if max_request_bytes == 0 {
|
||||
return Err(LimitConfigurationError::ZeroRequestBytes);
|
||||
@@ -31,14 +28,10 @@ impl Limits {
|
||||
if max_nesting == 0 {
|
||||
return Err(LimitConfigurationError::ZeroNesting);
|
||||
}
|
||||
if max_batch_items == 0 {
|
||||
return Err(LimitConfigurationError::ZeroBatchItems);
|
||||
}
|
||||
Ok(Self {
|
||||
max_request_bytes,
|
||||
max_response_bytes,
|
||||
max_nesting,
|
||||
max_batch_items,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -59,12 +52,6 @@ impl Limits {
|
||||
pub const fn max_nesting(self) -> usize {
|
||||
self.max_nesting
|
||||
}
|
||||
|
||||
/// Returns the batch item count limit.
|
||||
#[must_use]
|
||||
pub const fn max_batch_items(self) -> usize {
|
||||
self.max_batch_items
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a [`Limits`] configuration is not bounded.
|
||||
@@ -76,8 +63,6 @@ pub enum LimitConfigurationError {
|
||||
ZeroResponseBytes,
|
||||
/// Nesting limit was zero.
|
||||
ZeroNesting,
|
||||
/// Batch-item limit was zero.
|
||||
ZeroBatchItems,
|
||||
}
|
||||
|
||||
impl Display for LimitConfigurationError {
|
||||
@@ -86,7 +71,6 @@ impl Display for LimitConfigurationError {
|
||||
Self::ZeroRequestBytes => "request byte limit must be nonzero",
|
||||
Self::ZeroResponseBytes => "response byte limit must be nonzero",
|
||||
Self::ZeroNesting => "nesting limit must be nonzero",
|
||||
Self::ZeroBatchItems => "batch item limit must be nonzero",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -102,15 +86,6 @@ pub enum BodyKind {
|
||||
Response,
|
||||
}
|
||||
|
||||
/// The class of a resource limit exceeded by a wire representation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ResourceLimitKind {
|
||||
/// A request or response representation exceeded its byte-size limit.
|
||||
RepresentationBytes,
|
||||
/// A logical limit such as nesting depth, batch count, or collection size was exceeded.
|
||||
Logical,
|
||||
}
|
||||
|
||||
/// A malformed or locally disallowed wire representation.
|
||||
#[derive(Debug)]
|
||||
pub enum CodecError {
|
||||
@@ -157,47 +132,6 @@ pub enum CodecError {
|
||||
},
|
||||
/// Logical protocol validation rejected the decoded representation.
|
||||
ProtocolValidation(wip_protocol::ValidationError),
|
||||
/// The batch exceeds its configured item count.
|
||||
TooManyBatchItems {
|
||||
/// Actual item count.
|
||||
actual: usize,
|
||||
/// Configured maximum.
|
||||
maximum: usize,
|
||||
},
|
||||
/// Batch item identifiers are not unique.
|
||||
DuplicateBatchId {
|
||||
/// Repeated identifier.
|
||||
id: String,
|
||||
},
|
||||
/// A response contains a batch identifier that was not requested.
|
||||
UnexpectedBatchId {
|
||||
/// Unexpected identifier.
|
||||
id: String,
|
||||
},
|
||||
/// A response omitted a requested batch identifier.
|
||||
MissingBatchId {
|
||||
/// Missing identifier.
|
||||
id: String,
|
||||
},
|
||||
/// A batch item result does not match its requested interaction.
|
||||
BatchInteractionMismatch {
|
||||
/// Item identifier.
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl CodecError {
|
||||
/// Classifies this failure when it represents a canonical resource limit.
|
||||
#[must_use]
|
||||
pub const fn resource_limit_kind(&self) -> Option<ResourceLimitKind> {
|
||||
match self {
|
||||
Self::BodyTooLarge { .. } => Some(ResourceLimitKind::RepresentationBytes),
|
||||
Self::NestingTooDeep { .. } | Self::TooManyBatchItems { .. } => {
|
||||
Some(ResourceLimitKind::Logical)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for CodecError {
|
||||
@@ -225,15 +159,6 @@ impl Display for CodecError {
|
||||
write!(formatter, "field `{field}` is not canonical base64")
|
||||
}
|
||||
Self::ProtocolValidation(error) => error.fmt(formatter),
|
||||
Self::TooManyBatchItems { actual, maximum } => {
|
||||
write!(formatter, "batch has {actual} items; maximum is {maximum}")
|
||||
}
|
||||
Self::DuplicateBatchId { id } => write!(formatter, "duplicate batch id `{id}`"),
|
||||
Self::UnexpectedBatchId { id } => write!(formatter, "unexpected batch id `{id}`"),
|
||||
Self::MissingBatchId { id } => write!(formatter, "missing batch id `{id}`"),
|
||||
Self::BatchInteractionMismatch { id } => {
|
||||
write!(formatter, "batch result does not match request `{id}`")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,10 @@ use serde::de::{self, Deserialize, Deserializer, MapAccess, SeqAccess, Visitor};
|
||||
use serde_json::{Map, Number, Value as Json};
|
||||
use wip_protocol::{
|
||||
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
|
||||
FetchInterfaceResponse, FetchRequest, FetchTreeRequest, FetchTreeResponse, FieldDeclaration,
|
||||
INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceTarget, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER,
|
||||
Object, ObjectTreeNode, OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target,
|
||||
TypeDeclaration, TypeExpr, UnionCase, Value,
|
||||
FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor,
|
||||
InterfaceReference, InterfaceTarget, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, Object,
|
||||
ObjectObservation, ObserveRequest, ObserveResponse, OperationDeclaration, ParameterDeclaration,
|
||||
ReturnDeclaration, Target, TypeDeclaration, TypeExpr, UnionCase, Value,
|
||||
};
|
||||
|
||||
use crate::{BodyKind, CodecError, Limits};
|
||||
@@ -271,36 +271,39 @@ fn insert_optional_bytes(map: &mut Map<String, Json>, name: &str, value: &Option
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_request_to_json(request: &FetchRequest) -> Json {
|
||||
serde_json::json!({"path": request.path})
|
||||
pub(crate) fn observe_request_to_json(request: &ObserveRequest) -> Json {
|
||||
serde_json::json!({"path": request.path, "depth": request.depth})
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_request_from_json(value: &Json) -> Result<FetchRequest, CodecError> {
|
||||
let fields = exact_object(value, "request", &["path"])?;
|
||||
let request = FetchRequest {
|
||||
path: string(required(fields, "path")?, "path")?,
|
||||
};
|
||||
request.validate()?;
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_tree_request_to_json(request: &FetchTreeRequest) -> Json {
|
||||
serde_json::json!({"path": request.path, "depth": request.depth, "limit": request.limit})
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_tree_request_from_json(value: &Json) -> Result<FetchTreeRequest, CodecError> {
|
||||
let fields = exact_object(value, "request", &["path", "depth", "limit"])?;
|
||||
let request = FetchTreeRequest {
|
||||
pub(crate) fn observe_request_from_json(value: &Json) -> Result<ObserveRequest, CodecError> {
|
||||
let fields = exact_object(value, "request", &["path", "depth"])?;
|
||||
let request = ObserveRequest {
|
||||
path: string(required(fields, "path")?, "path")?,
|
||||
depth: u32_number(required(fields, "depth")?, "depth")?,
|
||||
limit: u32_number(required(fields, "limit")?, "limit")?,
|
||||
};
|
||||
request.validate()?;
|
||||
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 {
|
||||
serde_json::json!({"interface": request.interface})
|
||||
serde_json::json!({"interface": interface_reference_to_json(&request.interface)})
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_interface_request_from_json(
|
||||
@@ -308,7 +311,7 @@ pub(crate) fn fetch_interface_request_from_json(
|
||||
) -> Result<FetchInterfaceRequest, CodecError> {
|
||||
let fields = exact_object(value, "request", &["interface"])?;
|
||||
Ok(FetchInterfaceRequest {
|
||||
interface: string(required(fields, "interface")?, "interface")?,
|
||||
interface: interface_reference_from_json(required(fields, "interface")?, "interface")?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -322,8 +325,7 @@ pub(crate) fn object_to_json(object: &Object) -> Json {
|
||||
object
|
||||
.interfaces
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(Json::String)
|
||||
.map(interface_reference_to_json)
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
@@ -341,7 +343,7 @@ pub(crate) fn object_from_json(value: &Json, field: &str) -> Result<Object, Code
|
||||
let interfaces = array(required(fields, "interfaces")?, "interfaces")?
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, value)| string(value, &format!("interfaces[{index}]")))
|
||||
.map(|(index, value)| interface_reference_from_json(value, &format!("interfaces[{index}]")))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let object = Object {
|
||||
name: string(required(fields, "name")?, "name")?,
|
||||
@@ -360,35 +362,43 @@ pub(crate) fn object_from_json(value: &Json, field: &str) -> Result<Object, Code
|
||||
Ok(object)
|
||||
}
|
||||
|
||||
fn tree_node_to_json(node: &ObjectTreeNode) -> Json {
|
||||
serde_json::json!({
|
||||
"object": object_to_json(&node.object),
|
||||
"children": node.children.iter().map(tree_node_to_json).collect::<Vec<_>>()
|
||||
})
|
||||
fn observation_to_json(node: &ObjectObservation) -> Json {
|
||||
let mut fields = Map::new();
|
||||
fields.insert("object".into(), object_to_json(&node.object));
|
||||
if let Some(children) = &node.children {
|
||||
fields.insert(
|
||||
"children".into(),
|
||||
Json::Array(children.iter().map(observation_to_json).collect()),
|
||||
);
|
||||
}
|
||||
Json::Object(fields)
|
||||
}
|
||||
|
||||
fn tree_node_from_json(value: &Json, field: &str) -> Result<ObjectTreeNode, CodecError> {
|
||||
fn observation_from_json(value: &Json, field: &str) -> Result<ObjectObservation, CodecError> {
|
||||
let fields = exact_object(value, field, &["object", "children"])?;
|
||||
Ok(ObjectTreeNode {
|
||||
let children = optional(fields, "children")?
|
||||
.map(|value| {
|
||||
array(value, &qualified(field, "children"))?
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, child)| {
|
||||
observation_from_json(child, &format!("{field}.children[{index}]"))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(ObjectObservation {
|
||||
object: object_from_json(required(fields, "object")?, &qualified(field, "object"))?,
|
||||
children: array(required(fields, "children")?, &qualified(field, "children"))?
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, child)| tree_node_from_json(child, &format!("{field}.children[{index}]")))
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
children,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_tree_response_to_json(response: &FetchTreeResponse) -> Json {
|
||||
serde_json::json!({"root": tree_node_to_json(&response.root), "truncated": response.truncated})
|
||||
pub(crate) fn observe_response_to_json(response: &ObserveResponse) -> Json {
|
||||
observation_to_json(response)
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_tree_response_from_json(value: &Json) -> Result<FetchTreeResponse, CodecError> {
|
||||
let fields = exact_object(value, "response", &["root", "truncated"])?;
|
||||
Ok(FetchTreeResponse {
|
||||
root: tree_node_from_json(required(fields, "root")?, "root")?,
|
||||
truncated: boolean(required(fields, "truncated")?, "truncated")?,
|
||||
})
|
||||
pub(crate) fn observe_response_from_json(value: &Json) -> Result<ObserveResponse, CodecError> {
|
||||
observation_from_json(value, "response")
|
||||
}
|
||||
|
||||
fn documentation_to_json(documentation: &Documentation) -> Json {
|
||||
@@ -751,7 +761,11 @@ pub(crate) fn descriptor_from_json(
|
||||
|
||||
pub(crate) fn fetch_interface_response_to_json(response: &FetchInterfaceResponse) -> Json {
|
||||
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(
|
||||
"descriptor".into(),
|
||||
descriptor_to_json(&response.descriptor),
|
||||
@@ -763,8 +777,15 @@ pub(crate) fn fetch_interface_response_to_json(response: &FetchInterfaceResponse
|
||||
pub(crate) fn fetch_interface_response_from_json(
|
||||
value: &Json,
|
||||
) -> Result<FetchInterfaceResponse, CodecError> {
|
||||
let fields = exact_object(value, "response", &["interface", "descriptor", "validator"])?;
|
||||
let interface = string(required(fields, "interface")?, "interface")?;
|
||||
let fields = exact_object(
|
||||
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")?
|
||||
.map(|value| base64_decode(value, "validator"))
|
||||
.transpose()?;
|
||||
@@ -786,6 +807,7 @@ pub(crate) fn fetch_interface_response_from_json(
|
||||
};
|
||||
Ok(FetchInterfaceResponse {
|
||||
interface,
|
||||
scope_ref,
|
||||
descriptor,
|
||||
validator,
|
||||
})
|
||||
@@ -1127,18 +1149,25 @@ fn target_from_json(value: &Json, field: &str) -> Result<Target, CodecError> {
|
||||
|
||||
fn interface_target_to_json(target: &InterfaceTarget) -> Json {
|
||||
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);
|
||||
Json::Object(fields)
|
||||
}
|
||||
|
||||
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 {
|
||||
reference: string(
|
||||
reference: interface_reference_from_json(
|
||||
required(fields, "reference")?,
|
||||
&qualified(field, "reference"),
|
||||
)?,
|
||||
scope_ref: optional(fields, "scope_ref")?
|
||||
.map(|value| string(value, &qualified(field, "scope_ref")))
|
||||
.transpose()?,
|
||||
validator: optional(fields, "validator")?
|
||||
.map(|value| base64_decode(value, &qualified(field, "validator")))
|
||||
.transpose()?,
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Framework-independent standard WIP over HTTP v1 binding.
|
||||
//!
|
||||
//! This crate owns endpoint and route derivation, UTF-8 JSON encoding, canonical
|
||||
//! success and error response handling, HTTP status mapping, descriptor-guided
|
||||
//! operation-value conversion. It deliberately performs no I/O and depends on no
|
||||
//! server framework, TLS implementation, filesystem projection, client cache, or
|
||||
//! presentation layer. URL schemes, TLS, certificates, authentication mechanisms,
|
||||
//! proxies, and caches are selected by the HTTP implementation and deployment
|
||||
//! rather than by WIP conformance.
|
||||
//!
|
||||
//! Logical values remain owned by [`wip_protocol`]. Validators and `Bytes` are
|
||||
//! encoded in JSON bodies using canonical RFC 4648 Section 4 base64; this crate
|
||||
//! 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_protocol::ObserveRequest;
|
||||
//!
|
||||
//! let endpoint = Endpoint::parse("http://example.test/api/wip")?;
|
||||
//! let limits = Limits::new(64 * 1024, 1024 * 1024, 64)?;
|
||||
//! let request = encode_observe_request(
|
||||
//! &endpoint,
|
||||
//! &ObserveRequest { path: "/items/123".into(), depth: 0 },
|
||||
//! limits,
|
||||
//! )?;
|
||||
//! assert_eq!(request.uri(), "http://example.test/api/wip/v1/observe");
|
||||
//! assert_eq!(request.method(), http::Method::POST);
|
||||
//! # Ok::<(), Box<dyn std::error::Error>>(())
|
||||
//! ```
|
||||
|
||||
#![deny(missing_docs)]
|
||||
|
||||
mod codec;
|
||||
mod endpoint;
|
||||
mod error;
|
||||
mod json;
|
||||
mod response;
|
||||
|
||||
pub use codec::{
|
||||
CallOperationMetadata, EncodeHttpError, RequestMetadataError, decode_call_operation_metadata,
|
||||
decode_call_operation_request, decode_call_operation_response, decode_fetch_interface_request,
|
||||
decode_fetch_interface_response, decode_observe_request, decode_observe_response,
|
||||
encode_call_operation_request, encode_call_operation_response, encode_fetch_interface_request,
|
||||
encode_fetch_interface_response, encode_observe_request, encode_observe_response,
|
||||
encode_protocol_error_response, is_json_content_type, validate_http_request,
|
||||
};
|
||||
pub use endpoint::{Endpoint, EndpointError, JSON_CONTENT_TYPE, Route};
|
||||
pub use error::{BodyKind, CodecError, LimitConfigurationError, Limits};
|
||||
pub use response::{
|
||||
ClientResponseError, DecodedResponse, InvalidResponseKind, StatusMismatch,
|
||||
TransportBindingFailure, TransportFailureKind, error_code_name, status_for_error,
|
||||
};
|
||||
|
||||
/// Framework-neutral HTTP types used by this binding's public API.
|
||||
pub use http;
|
||||
/// URL type used by [`Endpoint`].
|
||||
pub use url;
|
||||
/// Transport-independent logical protocol model used by this binding.
|
||||
pub use wip_protocol;
|
||||
@@ -5,7 +5,7 @@ use http::StatusCode;
|
||||
use serde_json::{Map, Value as Json};
|
||||
use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction};
|
||||
|
||||
use crate::{BodyKind, CodecError, Limits, ResourceLimitKind, json};
|
||||
use crate::{BodyKind, CodecError, Limits, json};
|
||||
|
||||
/// A successfully classified client response.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -37,6 +37,8 @@ pub enum TransportFailureKind {
|
||||
AuthenticationRequired,
|
||||
/// HTTP 403, commonly emitted by endpoint policy or infrastructure.
|
||||
EndpointForbidden,
|
||||
/// The response uses a content coding the client does not support.
|
||||
UnsupportedContentCoding,
|
||||
/// Another non-success HTTP response without a WIP error envelope.
|
||||
HttpStatus,
|
||||
}
|
||||
@@ -63,8 +65,6 @@ pub enum InvalidResponseKind {
|
||||
NonCanonicalSuccessStatus,
|
||||
/// A 2xx body was malformed or semantically invalid.
|
||||
InvalidSuccessBody,
|
||||
/// A batch response did not correlate exactly with its request.
|
||||
InvalidBatch,
|
||||
}
|
||||
|
||||
/// A local failure while classifying or validating a peer response.
|
||||
@@ -119,10 +119,6 @@ impl Display for ClientResponseError {
|
||||
impl Error for ClientResponseError {}
|
||||
|
||||
/// Returns the canonical HTTP status for a wire-level protocol error.
|
||||
///
|
||||
/// [`ProtocolErrorCode::ResourceLimitExceeded`] defaults to the logical-limit
|
||||
/// status. Use [`status_for_resource_limit`] when the concrete limit class is
|
||||
/// known.
|
||||
#[must_use]
|
||||
pub const fn status_for_error(code: ProtocolErrorCode) -> StatusCode {
|
||||
match code {
|
||||
@@ -145,22 +141,8 @@ pub const fn status_for_error(code: ProtocolErrorCode) -> StatusCode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the canonical HTTP status for a concrete resource-limit class.
|
||||
#[must_use]
|
||||
pub const fn status_for_resource_limit(kind: ResourceLimitKind) -> StatusCode {
|
||||
match kind {
|
||||
ResourceLimitKind::RepresentationBytes => StatusCode::PAYLOAD_TOO_LARGE,
|
||||
ResourceLimitKind::Logical => StatusCode::UNPROCESSABLE_ENTITY,
|
||||
}
|
||||
}
|
||||
|
||||
fn status_matches_error(status: StatusCode, code: ProtocolErrorCode) -> bool {
|
||||
match code {
|
||||
ProtocolErrorCode::ResourceLimitExceeded => {
|
||||
status == StatusCode::PAYLOAD_TOO_LARGE || status == StatusCode::UNPROCESSABLE_ENTITY
|
||||
}
|
||||
_ => status == status_for_error(code),
|
||||
}
|
||||
status == status_for_error(code)
|
||||
}
|
||||
|
||||
/// Returns the stable snake-case wire spelling of a protocol code.
|
||||
@@ -213,14 +195,7 @@ pub(crate) fn error_to_json(error: &ProtocolError) -> Json {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn item_error_to_json(error: &ProtocolError) -> Json {
|
||||
serde_json::json!({
|
||||
"code": error_code_name(error.code),
|
||||
"message": error.message,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn item_error_from_json(value: &Json) -> Result<ProtocolError, CodecError> {
|
||||
fn protocol_error_from_json(value: &Json) -> Result<ProtocolError, CodecError> {
|
||||
let fields = value
|
||||
.as_object()
|
||||
.ok_or_else(|| invalid_field("error", "expected object"))?;
|
||||
@@ -239,7 +214,7 @@ fn envelope_error_from_json(value: &Json) -> Result<Option<ProtocolError>, Codec
|
||||
if fields.len() != 1 || !fields.contains_key("error") {
|
||||
return Ok(None);
|
||||
}
|
||||
item_error_from_json(&fields["error"]).map(Some)
|
||||
protocol_error_from_json(&fields["error"]).map(Some)
|
||||
}
|
||||
|
||||
fn exact(fields: &Map<String, Json>, allowed: &[&str], parent: &str) -> Result<(), CodecError> {
|
||||
@@ -355,6 +330,13 @@ pub(crate) fn decode<T>(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn unsupported_content_coding(status: StatusCode) -> ClientResponseError {
|
||||
ClientResponseError::TransportBinding(TransportBindingFailure {
|
||||
status,
|
||||
kind: TransportFailureKind::UnsupportedContentCoding,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn transport_failure(status: StatusCode) -> ClientResponseError {
|
||||
let kind = match status {
|
||||
StatusCode::UNAUTHORIZED => TransportFailureKind::AuthenticationRequired,
|
||||
@@ -1,37 +1,42 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use wip_https::http::StatusCode;
|
||||
use wip_https::wip_protocol::{
|
||||
use wip_http::http::StatusCode;
|
||||
use wip_http::wip_protocol::{
|
||||
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
|
||||
FetchInterfaceResponse, FetchRequest, FetchTreeRequest, FetchTreeResponse, FieldDeclaration,
|
||||
INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceTarget, MAX_SAFE_INTEGER, Object,
|
||||
ObjectTreeNode, OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target,
|
||||
FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor,
|
||||
InterfaceReference, InterfaceTarget, MAX_SAFE_INTEGER, Object, ObjectObservation,
|
||||
ObserveRequest, OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target,
|
||||
TypeDeclaration, TypeExpr, UnionCase, Value,
|
||||
};
|
||||
use wip_https::{
|
||||
use wip_http::{
|
||||
ClientResponseError, CodecError, DecodedResponse, Endpoint, InvalidResponseKind, Limits,
|
||||
ResourceLimitKind, decode_call_operation_metadata, decode_call_operation_request,
|
||||
decode_call_operation_response, decode_fetch_interface_request,
|
||||
decode_fetch_interface_response, decode_fetch_request, decode_fetch_response,
|
||||
decode_fetch_tree_request, decode_fetch_tree_response, encode_call_operation_request,
|
||||
encode_call_operation_response, encode_fetch_interface_request,
|
||||
encode_fetch_interface_response, encode_fetch_request, encode_fetch_response,
|
||||
encode_fetch_tree_request, encode_fetch_tree_response,
|
||||
decode_call_operation_metadata, decode_call_operation_request, decode_call_operation_response,
|
||||
decode_fetch_interface_request, decode_fetch_interface_response, decode_observe_request,
|
||||
decode_observe_response, encode_call_operation_request, encode_call_operation_response,
|
||||
encode_fetch_interface_request, encode_fetch_interface_response, encode_observe_request,
|
||||
encode_observe_response,
|
||||
};
|
||||
|
||||
fn limits() -> Limits {
|
||||
Limits::new(1_000_000, 1_000_000, 100, 100).unwrap()
|
||||
Limits::new(1_000_000, 1_000_000, 100).unwrap()
|
||||
}
|
||||
|
||||
fn endpoint() -> Endpoint {
|
||||
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 {
|
||||
Object {
|
||||
name: name.into(),
|
||||
description: Some("description".into()),
|
||||
interfaces: vec!["interface-v1".into()],
|
||||
interfaces: vec![reference("/", "interface-v1")],
|
||||
r#ref: Some(format!("object:{name}")),
|
||||
validator: Some(validator.to_vec()),
|
||||
}
|
||||
@@ -154,7 +159,8 @@ fn call_request() -> CallOperationRequest {
|
||||
validator: Some(vec![0, 1, 2, 255]),
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: "interface-v1".into(),
|
||||
reference: reference("/", "interface-v1"),
|
||||
scope_ref: None,
|
||||
validator: Some(vec![9, 8, 7]),
|
||||
},
|
||||
operation: "everything".into(),
|
||||
@@ -196,14 +202,11 @@ fn call_request() -> CallOperationRequest {
|
||||
|
||||
#[test]
|
||||
fn every_single_interaction_request_accepts_json() {
|
||||
let fetch =
|
||||
encode_fetch_request(&endpoint(), &FetchRequest { path: "/".into() }, limits()).unwrap();
|
||||
let fetch_tree = encode_fetch_tree_request(
|
||||
let observe = encode_observe_request(
|
||||
&endpoint(),
|
||||
&FetchTreeRequest {
|
||||
&ObserveRequest {
|
||||
path: "/".into(),
|
||||
depth: 1,
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
},
|
||||
limits(),
|
||||
)
|
||||
@@ -211,7 +214,7 @@ fn every_single_interaction_request_accepts_json() {
|
||||
let fetch_interface = encode_fetch_interface_request(
|
||||
&endpoint(),
|
||||
&FetchInterfaceRequest {
|
||||
interface: "interface-v1".into(),
|
||||
interface: reference("/", "interface-v1"),
|
||||
},
|
||||
limits(),
|
||||
)
|
||||
@@ -219,63 +222,79 @@ fn every_single_interaction_request_accepts_json() {
|
||||
let call = encode_call_operation_request(&endpoint(), &call_request(), &descriptor(), limits())
|
||||
.unwrap();
|
||||
|
||||
for request in [fetch, fetch_tree, fetch_interface, call] {
|
||||
for request in [observe, fetch_interface, call] {
|
||||
assert_eq!(request.headers()["accept"], "application/json");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_round_trips_direct_body_and_body_validator() {
|
||||
let request = FetchRequest {
|
||||
fn depth_zero_observe_round_trips_direct_body_and_validator() {
|
||||
let request = ObserveRequest {
|
||||
path: "/items/123".into(),
|
||||
depth: 0,
|
||||
};
|
||||
let encoded_request = encode_fetch_request(&endpoint(), &request, limits()).unwrap();
|
||||
assert_eq!(encoded_request.uri().path(), "/wip/v1/fetch");
|
||||
let encoded_request = encode_observe_request(&endpoint(), &request, limits()).unwrap();
|
||||
assert_eq!(encoded_request.uri().path(), "/wip/v1/observe");
|
||||
assert_eq!(
|
||||
decode_fetch_request(encoded_request.body(), limits()).unwrap(),
|
||||
decode_observe_request(encoded_request.body(), limits()).unwrap(),
|
||||
request
|
||||
);
|
||||
|
||||
let expected = object("123", &[0, 1, 2, 255]);
|
||||
let encoded = encode_fetch_response(&request, &expected, limits()).unwrap();
|
||||
let expected = ObjectObservation {
|
||||
object: object("123", &[0, 1, 2, 255]),
|
||||
children: None,
|
||||
};
|
||||
let encoded = encode_observe_response(&request, &expected, limits()).unwrap();
|
||||
assert_eq!(encoded.status(), StatusCode::OK);
|
||||
assert!(
|
||||
!std::str::from_utf8(encoded.body())
|
||||
.unwrap()
|
||||
.contains("children")
|
||||
);
|
||||
assert_eq!(
|
||||
decode_fetch_response(&request, &encoded, limits()).unwrap(),
|
||||
decode_observe_response(&request, &encoded, limits()).unwrap(),
|
||||
DecodedResponse::Success(expected)
|
||||
);
|
||||
assert!(encoded.headers().get("etag").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_and_interface_round_trip_nested_logical_models() {
|
||||
let tree_request = FetchTreeRequest {
|
||||
fn observe_and_interface_round_trip_nested_logical_models() {
|
||||
let observe_request = ObserveRequest {
|
||||
path: "/items".into(),
|
||||
depth: 1,
|
||||
limit: 2,
|
||||
};
|
||||
let encoded_request = encode_fetch_tree_request(&endpoint(), &tree_request, limits()).unwrap();
|
||||
let encoded_request = encode_observe_request(&endpoint(), &observe_request, limits()).unwrap();
|
||||
assert_eq!(
|
||||
decode_fetch_tree_request(encoded_request.body(), limits()).unwrap(),
|
||||
tree_request
|
||||
decode_observe_request(encoded_request.body(), limits()).unwrap(),
|
||||
observe_request
|
||||
);
|
||||
let tree = FetchTreeResponse {
|
||||
root: ObjectTreeNode {
|
||||
object: object("items", b"root-validator"),
|
||||
children: vec![ObjectTreeNode {
|
||||
object: object("123", b"child-validator"),
|
||||
children: vec![],
|
||||
}],
|
||||
},
|
||||
truncated: false,
|
||||
let observation = ObjectObservation {
|
||||
object: object("items", b"root-validator"),
|
||||
children: Some(vec![ObjectObservation {
|
||||
object: object("123", b"child-validator"),
|
||||
children: None,
|
||||
}]),
|
||||
};
|
||||
let encoded = encode_fetch_tree_response(&tree_request, &tree, limits()).unwrap();
|
||||
let encoded = encode_observe_response(&observe_request, &observation, limits()).unwrap();
|
||||
assert_eq!(
|
||||
decode_fetch_tree_response(&tree_request, &encoded, limits()).unwrap(),
|
||||
DecodedResponse::Success(tree)
|
||||
decode_observe_response(&observe_request, &encoded, limits()).unwrap(),
|
||||
DecodedResponse::Success(observation)
|
||||
);
|
||||
|
||||
let empty = ObjectObservation {
|
||||
object: object("items", b"root-validator"),
|
||||
children: Some(vec![]),
|
||||
};
|
||||
let encoded_empty = encode_observe_response(&observe_request, &empty, limits()).unwrap();
|
||||
assert!(
|
||||
std::str::from_utf8(encoded_empty.body())
|
||||
.unwrap()
|
||||
.contains("\"children\":[]")
|
||||
);
|
||||
|
||||
let interface_request = FetchInterfaceRequest {
|
||||
interface: "interface-v1".into(),
|
||||
interface: reference("/", "interface-v1"),
|
||||
};
|
||||
let encoded_request =
|
||||
encode_fetch_interface_request(&endpoint(), &interface_request, limits()).unwrap();
|
||||
@@ -285,6 +304,7 @@ fn tree_and_interface_round_trip_nested_logical_models() {
|
||||
);
|
||||
let interface = FetchInterfaceResponse {
|
||||
interface: interface_request.interface.clone(),
|
||||
scope_ref: None,
|
||||
descriptor: descriptor(),
|
||||
validator: Some(vec![4, 5, 6, 255]),
|
||||
};
|
||||
@@ -363,14 +383,16 @@ fn descriptor_guided_values_and_validators_round_trip_both_directions() {
|
||||
#[test]
|
||||
fn malformed_unknown_null_and_noncanonical_base64_are_rejected() {
|
||||
let limits = limits();
|
||||
assert!(decode_fetch_request(br#"{"path":"/","extra":true}"#, limits).is_err());
|
||||
assert!(decode_fetch_request(br#"{"path":"relative","path":"/"}"#, limits).is_err());
|
||||
assert!(decode_observe_request(br#"{"path":"/","depth":0,"extra":true}"#, limits).is_err());
|
||||
assert!(
|
||||
decode_observe_request(br#"{"path":"relative","path":"/","depth":0}"#, limits).is_err()
|
||||
);
|
||||
|
||||
let request = call_request();
|
||||
let descriptor = descriptor();
|
||||
let body = serde_json::to_vec(&serde_json::json!({
|
||||
"target": {"path": "/items/123", "validator": null},
|
||||
"interface": {"reference": "interface-v1"},
|
||||
"interface": {"reference": {"scope": "/", "name": "interface-v1"}},
|
||||
"operation": "everything",
|
||||
"arguments": {}
|
||||
}))
|
||||
@@ -391,25 +413,17 @@ fn malformed_unknown_null_and_noncanonical_base64_are_rejected() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caller_limits_bound_bytes_and_nesting() {
|
||||
let tiny = Limits::new(8, 8, 2, 1).unwrap();
|
||||
let too_large = decode_fetch_request(br#"{"path":"/long"}"#, tiny).unwrap_err();
|
||||
fn caller_limits_remain_local_codec_failures() {
|
||||
let tiny = Limits::new(8, 8, 2).unwrap();
|
||||
let too_large = decode_observe_request(br#"{"path":"/long","depth":0}"#, tiny).unwrap_err();
|
||||
assert!(matches!(&too_large, CodecError::BodyTooLarge { .. }));
|
||||
assert_eq!(
|
||||
too_large.resource_limit_kind(),
|
||||
Some(ResourceLimitKind::RepresentationBytes)
|
||||
);
|
||||
|
||||
let nested = br#"{"path":[[["/"]]]}"#;
|
||||
let nesting_limited = Limits::new(1024, 1024, 2, 1).unwrap();
|
||||
let too_deep = decode_fetch_request(nested, nesting_limited).unwrap_err();
|
||||
let nested = br#"{"path":[[["/"]]],"depth":0}"#;
|
||||
let nesting_limited = Limits::new(1024, 1024, 2).unwrap();
|
||||
let too_deep = decode_observe_request(nested, nesting_limited).unwrap_err();
|
||||
assert!(matches!(&too_deep, CodecError::NestingTooDeep { .. }));
|
||||
assert_eq!(
|
||||
too_deep.resource_limit_kind(),
|
||||
Some(ResourceLimitKind::Logical)
|
||||
);
|
||||
|
||||
let outbound_limited = Limits::new(1_000_000, 1_000_000, 1, 10).unwrap();
|
||||
let outbound_limited = Limits::new(1_000_000, 1_000_000, 1).unwrap();
|
||||
assert!(
|
||||
encode_call_operation_request(
|
||||
&endpoint(),
|
||||
@@ -420,11 +434,15 @@ fn caller_limits_bound_bytes_and_nesting() {
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
encode_fetch_response(
|
||||
&FetchRequest {
|
||||
encode_observe_response(
|
||||
&ObserveRequest {
|
||||
path: "/item".into(),
|
||||
depth: 0,
|
||||
},
|
||||
&ObjectObservation {
|
||||
object: object("item", b"validator"),
|
||||
children: None,
|
||||
},
|
||||
&object("item", b"validator"),
|
||||
outbound_limited,
|
||||
)
|
||||
.is_err()
|
||||
@@ -434,10 +452,11 @@ fn caller_limits_bound_bytes_and_nesting() {
|
||||
#[test]
|
||||
fn unsupported_descriptor_format_is_client_local() {
|
||||
let request = FetchInterfaceRequest {
|
||||
interface: "interface-v1".into(),
|
||||
interface: reference("/", "interface-v1"),
|
||||
};
|
||||
let mut interface = FetchInterfaceResponse {
|
||||
interface: request.interface.clone(),
|
||||
scope_ref: None,
|
||||
descriptor: descriptor(),
|
||||
validator: None,
|
||||
};
|
||||
@@ -449,11 +468,11 @@ fn unsupported_descriptor_format_is_client_local() {
|
||||
ClientResponseError::UnsupportedDescriptorFormat { .. }
|
||||
));
|
||||
|
||||
let future_shape = wip_https::http::Response::builder()
|
||||
let future_shape = wip_http::http::Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("content-type", "application/json; charset=utf-8")
|
||||
.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(),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -464,11 +483,11 @@ fn unsupported_descriptor_format_is_client_local() {
|
||||
));
|
||||
|
||||
for malformed in [
|
||||
br#"{"interface":"wrong","descriptor":{"format":"wip-interface/2"}}"#.as_slice(),
|
||||
br#"{"interface":"interface-v1","descriptor":{"format":"wip-interface/2"},"validator":null}"#
|
||||
br#"{"interface":{"scope":"/","name":"wrong"},"descriptor":{"format":"wip-interface/2"}}"#.as_slice(),
|
||||
br#"{"interface":{"scope":"/","name":"interface-v1"},"descriptor":{"format":"wip-interface/2"},"validator":null}"#
|
||||
.as_slice(),
|
||||
] {
|
||||
let response = wip_https::http::Response::builder()
|
||||
let response = wip_http::http::Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("content-type", "application/json; charset=utf-8")
|
||||
.body(malformed.to_vec())
|
||||
@@ -485,14 +504,15 @@ fn unsupported_descriptor_format_is_client_local() {
|
||||
|
||||
#[test]
|
||||
fn invalid_success_is_classified_as_invalid_response() {
|
||||
let response = wip_https::http::Response::builder()
|
||||
let response = wip_http::http::Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("content-type", "application/json; charset=utf-8")
|
||||
.body(br#"{"name":"wrong","interfaces":[]}"#.to_vec())
|
||||
.body(br#"{"object":{"name":"wrong","interfaces":[]}}"#.to_vec())
|
||||
.unwrap();
|
||||
let error = decode_fetch_response(
|
||||
&FetchRequest {
|
||||
let error = decode_observe_response(
|
||||
&ObserveRequest {
|
||||
path: "/expected".into(),
|
||||
depth: 0,
|
||||
},
|
||||
&response,
|
||||
limits(),
|
||||
@@ -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,20 +1,22 @@
|
||||
use wip_https::http::{Response, StatusCode};
|
||||
use wip_https::wip_protocol::{
|
||||
FetchRequest, ProtocolError, ProtocolErrorCode, ProtocolInteraction,
|
||||
use wip_http::http::{Response, StatusCode};
|
||||
use wip_http::wip_protocol::{
|
||||
ObserveRequest, ProtocolError, ProtocolErrorCode, ProtocolInteraction,
|
||||
};
|
||||
use wip_https::{
|
||||
ClientResponseError, DecodedResponse, InvalidResponseKind, Limits, ResourceLimitKind,
|
||||
TransportFailureKind, decode_fetch_response, encode_protocol_error_response,
|
||||
encode_resource_limit_error_response, error_code_name, is_json_content_type, status_for_error,
|
||||
status_for_resource_limit,
|
||||
use wip_http::{
|
||||
ClientResponseError, DecodedResponse, InvalidResponseKind, Limits, TransportFailureKind,
|
||||
decode_observe_response, encode_protocol_error_response, error_code_name, is_json_content_type,
|
||||
status_for_error,
|
||||
};
|
||||
|
||||
fn limits() -> Limits {
|
||||
Limits::new(64 * 1024, 64 * 1024, 32, 16).unwrap()
|
||||
Limits::new(64 * 1024, 64 * 1024, 32).unwrap()
|
||||
}
|
||||
|
||||
fn request() -> FetchRequest {
|
||||
FetchRequest { path: "/".into() }
|
||||
fn request() -> ObserveRequest {
|
||||
ObserveRequest {
|
||||
path: "/".into(),
|
||||
depth: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn response(status: StatusCode, body: impl Into<Vec<u8>>) -> Response<Vec<u8>> {
|
||||
@@ -117,41 +119,45 @@ fn every_protocol_code_has_stable_name_and_status() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_limit_statuses_encode_and_decode_by_concrete_limit_class() {
|
||||
fn logical_resource_limit_uses_only_422_and_bare_413_is_transport_failure() {
|
||||
let error = ProtocolError {
|
||||
code: ProtocolErrorCode::ResourceLimitExceeded,
|
||||
message: "bounded".into(),
|
||||
message: "logical observation limit exceeded".into(),
|
||||
};
|
||||
let cases = [
|
||||
(
|
||||
ResourceLimitKind::RepresentationBytes,
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
),
|
||||
(ResourceLimitKind::Logical, StatusCode::UNPROCESSABLE_ENTITY),
|
||||
];
|
||||
for (kind, status) in cases {
|
||||
assert_eq!(status_for_resource_limit(kind), status);
|
||||
let encoded = encode_resource_limit_error_response(
|
||||
ProtocolInteraction::Fetch,
|
||||
&error,
|
||||
kind,
|
||||
limits(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(encoded.status(), status);
|
||||
assert_eq!(
|
||||
decode_fetch_response(&request(), &encoded, limits()).unwrap(),
|
||||
DecodedResponse::ProtocolFailure {
|
||||
error: error.clone(),
|
||||
status_mismatch: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
let encoded =
|
||||
encode_protocol_error_response(ProtocolInteraction::Observe, &error, limits()).unwrap();
|
||||
assert_eq!(encoded.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert_eq!(
|
||||
decode_observe_response(&request(), &encoded, limits()).unwrap(),
|
||||
DecodedResponse::ProtocolFailure {
|
||||
error: error.clone(),
|
||||
status_mismatch: None,
|
||||
}
|
||||
);
|
||||
|
||||
let default =
|
||||
encode_protocol_error_response(ProtocolInteraction::Fetch, &error, limits()).unwrap();
|
||||
assert_eq!(default.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert_ne!(default.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
let mut noncanonical = encoded;
|
||||
*noncanonical.status_mut() = StatusCode::PAYLOAD_TOO_LARGE;
|
||||
assert_eq!(
|
||||
decode_observe_response(&request(), &noncanonical, limits()).unwrap(),
|
||||
DecodedResponse::ProtocolFailure {
|
||||
error,
|
||||
status_mismatch: Some(wip_http::StatusMismatch {
|
||||
actual: StatusCode::PAYLOAD_TOO_LARGE,
|
||||
expected: StatusCode::UNPROCESSABLE_ENTITY,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
let bare_413 = Response::builder()
|
||||
.status(StatusCode::PAYLOAD_TOO_LARGE)
|
||||
.body(Vec::<u8>::new())
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
decode_observe_response(&request(), &bare_413, limits()).unwrap_err(),
|
||||
ClientResponseError::TransportBinding(failure)
|
||||
if failure.status == StatusCode::PAYLOAD_TOO_LARGE
|
||||
&& failure.kind == TransportFailureKind::HttpStatus
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -161,19 +167,62 @@ fn rate_limit_status_without_protocol_envelope_stays_transport_failure() {
|
||||
.body(b"rate limited".to_vec())
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
decode_fetch_response(&request(), &response, limits()).unwrap_err(),
|
||||
decode_observe_response(&request(), &response, limits()).unwrap_err(),
|
||||
ClientResponseError::TransportBinding(failure)
|
||||
if failure.status == StatusCode::TOO_MANY_REQUESTS
|
||||
&& failure.kind == TransportFailureKind::HttpStatus
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_response_content_coding_is_transport_failure() {
|
||||
let mut encoded = encode_protocol_error_response(
|
||||
ProtocolInteraction::Observe,
|
||||
&ProtocolError {
|
||||
code: ProtocolErrorCode::NotFound,
|
||||
message: "missing".into(),
|
||||
},
|
||||
limits(),
|
||||
)
|
||||
.unwrap();
|
||||
encoded
|
||||
.headers_mut()
|
||||
.insert("content-encoding", "gzip".parse().unwrap());
|
||||
assert!(matches!(
|
||||
decode_observe_response(&request(), &encoded, limits()).unwrap_err(),
|
||||
ClientResponseError::TransportBinding(failure)
|
||||
if failure.status == StatusCode::NOT_FOUND
|
||||
&& failure.kind == TransportFailureKind::UnsupportedContentCoding
|
||||
));
|
||||
|
||||
let success = response(StatusCode::OK, br#"{"object":{"name":"","interfaces":[]}}"#);
|
||||
let mut identity = success.clone();
|
||||
identity
|
||||
.headers_mut()
|
||||
.insert("content-encoding", "identity".parse().unwrap());
|
||||
assert!(matches!(
|
||||
decode_observe_response(&request(), &identity, limits()).unwrap(),
|
||||
DecodedResponse::Success(_)
|
||||
));
|
||||
|
||||
let mut unsupported = success;
|
||||
unsupported
|
||||
.headers_mut()
|
||||
.insert("content-encoding", "br".parse().unwrap());
|
||||
assert!(matches!(
|
||||
decode_observe_response(&request(), &unsupported, limits()).unwrap_err(),
|
||||
ClientResponseError::TransportBinding(failure)
|
||||
if failure.status == StatusCode::OK
|
||||
&& failure.kind == TransportFailureKind::UnsupportedContentCoding
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_success_body_requires_exactly_http_200() {
|
||||
let success_body = br#"{"name":"","interfaces":[]}"#;
|
||||
let success_body = br#"{"object":{"name":"","interfaces":[]}}"#;
|
||||
for status in [StatusCode::CREATED, StatusCode::ACCEPTED] {
|
||||
assert!(matches!(
|
||||
decode_fetch_response(&request(), &response(status, success_body), limits())
|
||||
decode_observe_response(&request(), &response(status, success_body), limits())
|
||||
.unwrap_err(),
|
||||
ClientResponseError::InvalidResponse {
|
||||
kind: InvalidResponseKind::NonCanonicalSuccessStatus,
|
||||
@@ -187,9 +236,11 @@ fn valid_success_body_requires_exactly_http_200() {
|
||||
fn server_encoder_rejects_codes_disallowed_for_the_interaction() {
|
||||
let error = ProtocolError {
|
||||
code: ProtocolErrorCode::InterfaceNotFound,
|
||||
message: "not valid for fetch".into(),
|
||||
message: "not valid for observe".into(),
|
||||
};
|
||||
assert!(encode_protocol_error_response(ProtocolInteraction::Fetch, &error, limits()).is_err());
|
||||
assert!(
|
||||
encode_protocol_error_response(ProtocolInteraction::Observe, &error, limits()).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -199,14 +250,14 @@ fn valid_envelope_code_is_authoritative_when_status_mismatches() {
|
||||
message: "missing".into(),
|
||||
};
|
||||
let mut encoded =
|
||||
encode_protocol_error_response(ProtocolInteraction::Fetch, &error, limits()).unwrap();
|
||||
encode_protocol_error_response(ProtocolInteraction::Observe, &error, limits()).unwrap();
|
||||
*encoded.status_mut() = StatusCode::IM_A_TEAPOT;
|
||||
|
||||
assert_eq!(
|
||||
decode_fetch_response(&request(), &encoded, limits()).unwrap(),
|
||||
decode_observe_response(&request(), &encoded, limits()).unwrap(),
|
||||
DecodedResponse::ProtocolFailure {
|
||||
error,
|
||||
status_mismatch: Some(wip_https::StatusMismatch {
|
||||
status_mismatch: Some(wip_http::StatusMismatch {
|
||||
actual: StatusCode::IM_A_TEAPOT,
|
||||
expected: StatusCode::NOT_FOUND,
|
||||
}),
|
||||
@@ -218,7 +269,7 @@ fn valid_envelope_code_is_authoritative_when_status_mismatches() {
|
||||
fn envelope_and_status_contradictions_are_invalid_responses() {
|
||||
let error_body = br#"{"error":{"code":"not_found","message":"missing"}}"#;
|
||||
let on_success =
|
||||
decode_fetch_response(&request(), &response(StatusCode::OK, error_body), limits())
|
||||
decode_observe_response(&request(), &response(StatusCode::OK, error_body), limits())
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
on_success,
|
||||
@@ -228,8 +279,8 @@ fn envelope_and_status_contradictions_are_invalid_responses() {
|
||||
}
|
||||
));
|
||||
|
||||
let success_body = br#"{"name":"","interfaces":[]}"#;
|
||||
let on_error = decode_fetch_response(
|
||||
let success_body = br#"{"object":{"name":"","interfaces":[]}}"#;
|
||||
let on_error = decode_observe_response(
|
||||
&request(),
|
||||
&response(StatusCode::BAD_GATEWAY, success_body),
|
||||
limits(),
|
||||
@@ -247,8 +298,9 @@ fn envelope_and_status_contradictions_are_invalid_responses() {
|
||||
#[test]
|
||||
fn disallowed_protocol_code_is_invalid_instead_of_reclassified() {
|
||||
let body = br#"{"error":{"code":"interface_not_found","message":"wrong interaction"}}"#;
|
||||
let error = decode_fetch_response(&request(), &response(StatusCode::NOT_FOUND, body), limits())
|
||||
.unwrap_err();
|
||||
let error =
|
||||
decode_observe_response(&request(), &response(StatusCode::NOT_FOUND, body), limits())
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
ClientResponseError::InvalidResponse {
|
||||
@@ -282,7 +334,7 @@ fn proxy_authentication_and_endpoint_failures_stay_transport_failures() {
|
||||
.status(status)
|
||||
.body(body.to_vec())
|
||||
.unwrap();
|
||||
let error = decode_fetch_response(&request(), &response, limits()).unwrap_err();
|
||||
let error = decode_observe_response(&request(), &response, limits()).unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
ClientResponseError::TransportBinding(failure)
|
||||
@@ -298,7 +350,7 @@ fn malformed_non_success_envelope_is_transport_binding_failure() {
|
||||
br#"{"error":{"code":"not_found","message":null}}"#,
|
||||
);
|
||||
assert!(matches!(
|
||||
decode_fetch_response(&request(), &malformed, limits()).unwrap_err(),
|
||||
decode_observe_response(&request(), &malformed, limits()).unwrap_err(),
|
||||
ClientResponseError::TransportBinding(_)
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
use wip_http::http::{Method, Request, StatusCode};
|
||||
use wip_http::{Endpoint, RequestMetadataError, Route, validate_http_request};
|
||||
|
||||
#[test]
|
||||
fn endpoint_preserves_http_and_https_when_resolving_canonical_routes() {
|
||||
for scheme in ["http", "https"] {
|
||||
let endpoint = Endpoint::parse(&format!("{scheme}://example.test/root/prefix")).unwrap();
|
||||
assert_eq!(
|
||||
endpoint.to_string(),
|
||||
format!("{scheme}://example.test/root/prefix/")
|
||||
);
|
||||
|
||||
let expected = [
|
||||
(Route::Observe, "v1/observe"),
|
||||
(Route::FetchInterface, "v1/fetch_interface"),
|
||||
(Route::CallOperation, "v1/call_operation"),
|
||||
];
|
||||
for (route, suffix) in expected {
|
||||
let url = format!("{scheme}://example.test/root/prefix/{suffix}");
|
||||
assert_eq!(endpoint.route(route).as_str(), url);
|
||||
assert_eq!(
|
||||
endpoint.recognize(&wip_http::url::Url::parse(&url).unwrap()),
|
||||
Some(route)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
endpoint.recognize(
|
||||
&wip_http::url::Url::parse(&format!(
|
||||
"{scheme}://example.test/root/prefix/v1/observe?path=/items"
|
||||
))
|
||||
.unwrap()
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routes_never_mix_worldspace_paths_into_endpoint_prefixes() {
|
||||
let endpoint = Endpoint::parse("https://example.test/wip/%E4%B8%96%E7%95%8C/").unwrap();
|
||||
let route = endpoint.route(Route::Observe);
|
||||
assert_eq!(route.path(), "/wip/%E4%B8%96%E7%95%8C/v1/observe");
|
||||
assert!(!route.as_str().contains("items"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inbound_http_metadata_enforces_post_route_and_json() {
|
||||
let endpoint = Endpoint::parse("https://example.test/wip").unwrap();
|
||||
let valid = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/wip/v1/observe")
|
||||
.header("content-type", "application/json; charset=utf-8")
|
||||
.body(Vec::<u8>::new())
|
||||
.unwrap();
|
||||
assert!(validate_http_request(&endpoint, Route::Observe, &valid).is_ok());
|
||||
|
||||
let wrong_method = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/wip/v1/observe")
|
||||
.header("content-type", "application/json")
|
||||
.body(Vec::<u8>::new())
|
||||
.unwrap();
|
||||
let error = validate_http_request(&endpoint, Route::Observe, &wrong_method).unwrap_err();
|
||||
assert!(matches!(&error, RequestMetadataError::MethodNotAllowed));
|
||||
assert_eq!(error.status(), StatusCode::METHOD_NOT_ALLOWED);
|
||||
assert_eq!(error.allow(), Some("POST"));
|
||||
|
||||
let wrong_route = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/wip/v1/fetch_interface")
|
||||
.header("content-type", "application/json")
|
||||
.body(Vec::<u8>::new())
|
||||
.unwrap();
|
||||
let error = validate_http_request(&endpoint, Route::Observe, &wrong_route).unwrap_err();
|
||||
assert!(matches!(&error, RequestMetadataError::NotFound));
|
||||
assert_eq!(error.status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(error.allow(), None);
|
||||
|
||||
let query_and_wrong_method = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/wip/v1/observe?unexpected=true")
|
||||
.header("content-type", "application/json")
|
||||
.body(Vec::<u8>::new())
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
validate_http_request(&endpoint, Route::Observe, &query_and_wrong_method).unwrap_err(),
|
||||
RequestMetadataError::NotFound
|
||||
));
|
||||
|
||||
for accept in [
|
||||
"text/plain, */*;q=1, application/json;q=0",
|
||||
"application/json; charset=utf-16",
|
||||
"application/json; profile=future",
|
||||
] {
|
||||
let incompatible_accept = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/wip/v1/observe")
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", accept)
|
||||
.body(Vec::<u8>::new())
|
||||
.unwrap();
|
||||
let error =
|
||||
validate_http_request(&endpoint, Route::Observe, &incompatible_accept).unwrap_err();
|
||||
assert!(matches!(&error, RequestMetadataError::NotAcceptable));
|
||||
assert_eq!(error.status(), StatusCode::NOT_ACCEPTABLE);
|
||||
}
|
||||
|
||||
for accept in [
|
||||
"application/json; charset=UTF-8",
|
||||
"application/json; q=1; profile=accept-extension",
|
||||
] {
|
||||
let compatible_accept = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/wip/v1/observe")
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", accept)
|
||||
.body(Vec::<u8>::new())
|
||||
.unwrap();
|
||||
assert!(validate_http_request(&endpoint, Route::Observe, &compatible_accept).is_ok());
|
||||
}
|
||||
|
||||
let unsupported_media_type = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/wip/v1/observe")
|
||||
.header("content-type", "text/plain")
|
||||
.header("accept", "application/json")
|
||||
.body(Vec::<u8>::new())
|
||||
.unwrap();
|
||||
let error =
|
||||
validate_http_request(&endpoint, Route::Observe, &unsupported_media_type).unwrap_err();
|
||||
assert!(matches!(&error, RequestMetadataError::UnsupportedMediaType));
|
||||
assert_eq!(error.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
|
||||
|
||||
for coding in ["gzip", "identity, gzip"] {
|
||||
let unsupported_coding = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/wip/v1/observe")
|
||||
.header("content-type", "application/json")
|
||||
.header("content-encoding", coding)
|
||||
.body(Vec::<u8>::new())
|
||||
.unwrap();
|
||||
let error =
|
||||
validate_http_request(&endpoint, Route::Observe, &unsupported_coding).unwrap_err();
|
||||
assert!(matches!(&error, RequestMetadataError::UnsupportedMediaType));
|
||||
assert_eq!(error.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
|
||||
}
|
||||
|
||||
let identity_coding = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/wip/v1/observe")
|
||||
.header("content-type", "application/json")
|
||||
.header("content-encoding", "identity")
|
||||
.body(Vec::<u8>::new())
|
||||
.unwrap();
|
||||
assert!(validate_http_request(&endpoint, Route::Observe, &identity_coding).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_endpoint_components_and_non_routes_are_rejected() {
|
||||
assert!(Endpoint::parse("/relative").is_err());
|
||||
assert!(Endpoint::parse("file:///wip").is_err());
|
||||
assert!(Endpoint::parse("https://example.test/wip?q=1").is_err());
|
||||
assert!(Endpoint::parse("https://example.test/wip#fragment").is_err());
|
||||
|
||||
let deployment_endpoint = Endpoint::parse("http://user:secret@example.test/wip").unwrap();
|
||||
assert_eq!(deployment_endpoint.as_url().scheme(), "http");
|
||||
assert_eq!(deployment_endpoint.as_url().username(), "user");
|
||||
|
||||
let endpoint = Endpoint::parse("http://example.test/wip").unwrap();
|
||||
for outside in [
|
||||
"http://example.test/wip/v1/unknown",
|
||||
"http://example.test/wip/v1/batch",
|
||||
"http://example.test/wip/v1/observe/extra",
|
||||
"https://example.test/wip/v1/observe",
|
||||
"http://other.test/wip/v1/observe",
|
||||
] {
|
||||
assert_eq!(
|
||||
endpoint.recognize(&wip_http::url::Url::parse(outside).unwrap()),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
[package]
|
||||
name = "wip-https"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Framework-independent standard WIP over HTTPS v1 binding"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
http = "1"
|
||||
serde = "1"
|
||||
serde_json = "1"
|
||||
url = "2"
|
||||
wip-protocol = { path = "../wip-protocol" }
|
||||
@@ -1,596 +0,0 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use http::header::{ACCEPT, CONTENT_TYPE};
|
||||
use http::{Method, Request, Response, StatusCode};
|
||||
use serde_json::{Map, Value as Json};
|
||||
use wip_protocol::{
|
||||
FetchInterfaceRequest, FetchInterfaceResponse, FetchRequest, FetchResponse, FetchTreeRequest,
|
||||
FetchTreeResponse, INTERFACE_FORMAT_V1, ProtocolError, ProtocolErrorCode, ProtocolInteraction,
|
||||
};
|
||||
|
||||
use crate::response::{self, DecodedResponse};
|
||||
use crate::{
|
||||
BodyKind, ClientResponseError, CodecError, EncodeHttpError, Endpoint, InvalidResponseKind,
|
||||
JSON_CONTENT_TYPE, Limits, ResourceLimitKind, Route, is_json_content_type, json,
|
||||
};
|
||||
|
||||
/// A retrieval method accepted by the standard v1 batch endpoint.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BatchMethod {
|
||||
/// Fetch one object.
|
||||
Fetch,
|
||||
/// Fetch one bounded tree.
|
||||
FetchTree,
|
||||
/// Fetch one interface descriptor.
|
||||
FetchInterface,
|
||||
}
|
||||
|
||||
impl BatchMethod {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Fetch => "fetch",
|
||||
Self::FetchTree => "fetch_tree",
|
||||
Self::FetchInterface => "fetch_interface",
|
||||
}
|
||||
}
|
||||
|
||||
fn interaction(self) -> ProtocolInteraction {
|
||||
match self {
|
||||
Self::Fetch => ProtocolInteraction::Fetch,
|
||||
Self::FetchTree => ProtocolInteraction::FetchTree,
|
||||
Self::FetchInterface => ProtocolInteraction::FetchInterface,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One retrieval interaction accepted by the standard v1 batch endpoint.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum BatchRequestKind {
|
||||
/// Fetch one object.
|
||||
Fetch(FetchRequest),
|
||||
/// Fetch one bounded tree.
|
||||
FetchTree(FetchTreeRequest),
|
||||
/// Fetch one interface descriptor.
|
||||
FetchInterface(FetchInterfaceRequest),
|
||||
/// A known method whose `arguments` are not a valid logical request.
|
||||
Invalid {
|
||||
/// Method that was successfully identified from the item envelope.
|
||||
method: BatchMethod,
|
||||
/// Item-local `invalid_request` to return without rejecting other items.
|
||||
error: ProtocolError,
|
||||
},
|
||||
}
|
||||
|
||||
impl BatchRequestKind {
|
||||
fn interaction(&self) -> ProtocolInteraction {
|
||||
match self {
|
||||
Self::Fetch(_) => ProtocolInteraction::Fetch,
|
||||
Self::FetchTree(_) => ProtocolInteraction::FetchTree,
|
||||
Self::FetchInterface(_) => ProtocolInteraction::FetchInterface,
|
||||
Self::Invalid { method, .. } => method.interaction(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One uniquely identified batch subrequest.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BatchRequestItem {
|
||||
/// Identifier used only to correlate the corresponding subresponse.
|
||||
pub id: String,
|
||||
/// Retrieval interaction and arguments.
|
||||
pub request: BatchRequestKind,
|
||||
}
|
||||
|
||||
/// A standard v1 batch request.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BatchRequest {
|
||||
/// Independent subrequests. Identifiers must be unique.
|
||||
pub requests: Vec<BatchRequestItem>,
|
||||
}
|
||||
|
||||
/// Successful result of one batch item.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum BatchSuccess {
|
||||
/// Successful `fetch` result.
|
||||
Fetch(FetchResponse),
|
||||
/// Successful `fetch_tree` result.
|
||||
FetchTree(FetchTreeResponse),
|
||||
/// Successful `fetch_interface` result.
|
||||
FetchInterface(FetchInterfaceResponse),
|
||||
}
|
||||
|
||||
/// One batch subresponse, correlated by `id` rather than array position.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct BatchResponseItem {
|
||||
/// Identifier copied from its subrequest.
|
||||
pub id: String,
|
||||
/// Direct interaction result or canonical item-local protocol failure.
|
||||
pub outcome: Result<BatchSuccess, ProtocolError>,
|
||||
}
|
||||
|
||||
/// A valid standard v1 batch response.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct BatchResponse {
|
||||
/// Exactly one response for each requested identifier, in any order.
|
||||
pub responses: Vec<BatchResponseItem>,
|
||||
}
|
||||
|
||||
fn validate_ids<'a>(ids: impl Iterator<Item = &'a str>, maximum: usize) -> Result<(), CodecError> {
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut count = 0;
|
||||
for id in ids {
|
||||
count += 1;
|
||||
if !seen.insert(id) {
|
||||
return Err(CodecError::DuplicateBatchId { id: id.into() });
|
||||
}
|
||||
}
|
||||
if count > maximum {
|
||||
return Err(CodecError::TooManyBatchItems {
|
||||
actual: count,
|
||||
maximum,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn request_item_to_json(item: &BatchRequestItem) -> Result<Json, CodecError> {
|
||||
let (method, arguments) = match &item.request {
|
||||
BatchRequestKind::Fetch(value) => ("fetch", json::fetch_request_to_json(value)),
|
||||
BatchRequestKind::FetchTree(value) => {
|
||||
("fetch_tree", json::fetch_tree_request_to_json(value))
|
||||
}
|
||||
BatchRequestKind::FetchInterface(value) => (
|
||||
"fetch_interface",
|
||||
json::fetch_interface_request_to_json(value),
|
||||
),
|
||||
BatchRequestKind::Invalid { .. } => {
|
||||
return Err(CodecError::InvalidField {
|
||||
field: "requests".into(),
|
||||
reason: "item-local invalid requests cannot be encoded as client requests".into(),
|
||||
});
|
||||
}
|
||||
};
|
||||
Ok(serde_json::json!({"id": item.id, "method": method, "arguments": arguments}))
|
||||
}
|
||||
|
||||
fn request_to_json(request: &BatchRequest, limits: Limits) -> Result<Json, CodecError> {
|
||||
validate_ids(
|
||||
request.requests.iter().map(|item| item.id.as_str()),
|
||||
limits.max_batch_items,
|
||||
)?;
|
||||
for item in &request.requests {
|
||||
match &item.request {
|
||||
BatchRequestKind::Fetch(value) => value.validate()?,
|
||||
BatchRequestKind::FetchTree(value) => value.validate()?,
|
||||
BatchRequestKind::FetchInterface(_) | BatchRequestKind::Invalid { .. } => {}
|
||||
}
|
||||
}
|
||||
let requests = request
|
||||
.requests
|
||||
.iter()
|
||||
.map(request_item_to_json)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(serde_json::json!({"requests": requests}))
|
||||
}
|
||||
|
||||
fn request_from_json(value: &Json, limits: Limits) -> Result<BatchRequest, CodecError> {
|
||||
let fields = exact_object(value, "batch", &["requests"])?;
|
||||
let values = array(required(fields, "requests")?, "requests")?;
|
||||
if values.len() > limits.max_batch_items {
|
||||
return Err(CodecError::TooManyBatchItems {
|
||||
actual: values.len(),
|
||||
maximum: limits.max_batch_items,
|
||||
});
|
||||
}
|
||||
let mut requests = Vec::with_capacity(values.len());
|
||||
for (index, value) in values.iter().enumerate() {
|
||||
let field = format!("requests[{index}]");
|
||||
let fields = exact_object(value, &field, &["id", "method", "arguments"])?;
|
||||
let id = string(required(fields, "id")?, &format!("{field}.id"))?;
|
||||
let method_name = string(required(fields, "method")?, &format!("{field}.method"))?;
|
||||
let arguments = required(fields, "arguments")?;
|
||||
let method = match method_name.as_str() {
|
||||
"fetch" => BatchMethod::Fetch,
|
||||
"fetch_tree" => BatchMethod::FetchTree,
|
||||
"fetch_interface" => BatchMethod::FetchInterface,
|
||||
_ => {
|
||||
return Err(CodecError::InvalidField {
|
||||
field: format!("{field}.method"),
|
||||
reason: "batch supports only fetch, fetch_tree, and fetch_interface".into(),
|
||||
});
|
||||
}
|
||||
};
|
||||
let decoded = match method {
|
||||
BatchMethod::Fetch => {
|
||||
json::fetch_request_from_json(arguments).map(BatchRequestKind::Fetch)
|
||||
}
|
||||
BatchMethod::FetchTree => {
|
||||
json::fetch_tree_request_from_json(arguments).map(BatchRequestKind::FetchTree)
|
||||
}
|
||||
BatchMethod::FetchInterface => json::fetch_interface_request_from_json(arguments)
|
||||
.map(BatchRequestKind::FetchInterface),
|
||||
};
|
||||
let request = decoded.unwrap_or_else(|error| BatchRequestKind::Invalid {
|
||||
method,
|
||||
error: ProtocolError {
|
||||
code: ProtocolErrorCode::InvalidRequest,
|
||||
message: format!("invalid {} arguments: {error}", method.name()),
|
||||
},
|
||||
});
|
||||
requests.push(BatchRequestItem { id, request });
|
||||
}
|
||||
validate_ids(
|
||||
requests.iter().map(|item| item.id.as_str()),
|
||||
limits.max_batch_items,
|
||||
)?;
|
||||
Ok(BatchRequest { requests })
|
||||
}
|
||||
|
||||
/// Encodes a batch request using `POST v1/batch`.
|
||||
pub fn encode_batch_request(
|
||||
endpoint: &Endpoint,
|
||||
value: &BatchRequest,
|
||||
limits: Limits,
|
||||
) -> Result<Request<Vec<u8>>, EncodeHttpError> {
|
||||
let body = json::serialize(&request_to_json(value, limits)?, BodyKind::Request, limits)?;
|
||||
Ok(Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(endpoint.route(Route::Batch).as_str())
|
||||
.header(CONTENT_TYPE, JSON_CONTENT_TYPE)
|
||||
.header(ACCEPT, "application/json")
|
||||
.body(body)?)
|
||||
}
|
||||
|
||||
/// Decodes and validates a batch request body.
|
||||
pub fn decode_batch_request(body: &[u8], limits: Limits) -> Result<BatchRequest, CodecError> {
|
||||
let value = json::parse(body, BodyKind::Request, limits)?;
|
||||
request_from_json(&value, limits)
|
||||
}
|
||||
|
||||
fn success_to_json(success: &BatchSuccess) -> Json {
|
||||
match success {
|
||||
BatchSuccess::Fetch(value) => json::object_to_json(value),
|
||||
BatchSuccess::FetchTree(value) => json::fetch_tree_response_to_json(value),
|
||||
BatchSuccess::FetchInterface(value) => json::fetch_interface_response_to_json(value),
|
||||
}
|
||||
}
|
||||
|
||||
fn response_to_json(
|
||||
request: &BatchRequest,
|
||||
response: &BatchResponse,
|
||||
limits: Limits,
|
||||
) -> Result<Json, CodecError> {
|
||||
correlate(request, response, limits)?;
|
||||
Ok(serde_json::json!({
|
||||
"responses": response.responses.iter().map(|item| {
|
||||
let mut fields = Map::new();
|
||||
fields.insert("id".into(), Json::String(item.id.clone()));
|
||||
match &item.outcome {
|
||||
Ok(success) => { fields.insert("result".into(), success_to_json(success)); }
|
||||
Err(error) => { fields.insert("error".into(), response::item_error_to_json(error)); }
|
||||
}
|
||||
Json::Object(fields)
|
||||
}).collect::<Vec<_>>()
|
||||
}))
|
||||
}
|
||||
|
||||
fn correlate(
|
||||
request: &BatchRequest,
|
||||
response: &BatchResponse,
|
||||
limits: Limits,
|
||||
) -> Result<(), CodecError> {
|
||||
validate_ids(
|
||||
request.requests.iter().map(|item| item.id.as_str()),
|
||||
limits.max_batch_items,
|
||||
)?;
|
||||
validate_ids(
|
||||
response.responses.iter().map(|item| item.id.as_str()),
|
||||
limits.max_batch_items,
|
||||
)?;
|
||||
let requested: BTreeMap<&str, &BatchRequestKind> = request
|
||||
.requests
|
||||
.iter()
|
||||
.map(|item| (item.id.as_str(), &item.request))
|
||||
.collect();
|
||||
for item in &response.responses {
|
||||
let Some(kind) = requested.get(item.id.as_str()) else {
|
||||
return Err(CodecError::UnexpectedBatchId {
|
||||
id: item.id.clone(),
|
||||
});
|
||||
};
|
||||
match (&item.outcome, *kind) {
|
||||
(Ok(BatchSuccess::Fetch(value)), BatchRequestKind::Fetch(request)) => {
|
||||
request.validate_response(value)?;
|
||||
}
|
||||
(Ok(BatchSuccess::FetchTree(value)), BatchRequestKind::FetchTree(request)) => {
|
||||
value.validate_for(request)?;
|
||||
}
|
||||
(
|
||||
Ok(BatchSuccess::FetchInterface(value)),
|
||||
BatchRequestKind::FetchInterface(request),
|
||||
) => {
|
||||
value.validate_for(request)?;
|
||||
}
|
||||
(
|
||||
Err(error),
|
||||
BatchRequestKind::Invalid {
|
||||
error: expected, ..
|
||||
},
|
||||
) if error == expected => {}
|
||||
(Err(error), kind)
|
||||
if !matches!(kind, BatchRequestKind::Invalid { .. })
|
||||
&& error.code.is_allowed_for(kind.interaction()) => {}
|
||||
_ => {
|
||||
return Err(CodecError::BatchInteractionMismatch {
|
||||
id: item.id.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for item in &request.requests {
|
||||
if !response.responses.iter().any(|other| other.id == item.id) {
|
||||
return Err(CodecError::MissingBatchId {
|
||||
id: item.id.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Encodes a valid batch response. Item failures remain inside HTTP 200.
|
||||
pub fn encode_batch_response(
|
||||
request: &BatchRequest,
|
||||
value: &BatchResponse,
|
||||
limits: Limits,
|
||||
) -> Result<Response<Vec<u8>>, EncodeHttpError> {
|
||||
let body = json::serialize(
|
||||
&response_to_json(request, value, limits)?,
|
||||
BodyKind::Response,
|
||||
limits,
|
||||
)?;
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(CONTENT_TYPE, JSON_CONTENT_TYPE)
|
||||
.body(body)?)
|
||||
}
|
||||
|
||||
/// Encodes a top-level batch-envelope failure.
|
||||
///
|
||||
/// Item-local failures belong in [`BatchResponseItem`]. Only failures that can
|
||||
/// reject the batch envelope before item execution are accepted here. A
|
||||
/// resource-limit failure defaults to the logical-limit status; use
|
||||
/// [`encode_batch_resource_limit_error_response`] when the concrete class is
|
||||
/// known.
|
||||
pub fn encode_batch_error_response(
|
||||
error: &ProtocolError,
|
||||
limits: Limits,
|
||||
) -> Result<Response<Vec<u8>>, EncodeHttpError> {
|
||||
if !matches!(
|
||||
error.code,
|
||||
ProtocolErrorCode::InvalidRequest
|
||||
| ProtocolErrorCode::ResourceLimitExceeded
|
||||
| ProtocolErrorCode::Internal
|
||||
) {
|
||||
return Err(CodecError::InvalidField {
|
||||
field: "error.code".into(),
|
||||
reason: "code is not valid for a top-level batch failure".into(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
let body = json::serialize(&response::error_to_json(error), BodyKind::Response, limits)?;
|
||||
Ok(Response::builder()
|
||||
.status(response::status_for_error(error.code))
|
||||
.header(CONTENT_TYPE, JSON_CONTENT_TYPE)
|
||||
.body(body)?)
|
||||
}
|
||||
|
||||
/// Encodes a class-specific top-level batch `resource_limit_exceeded` failure.
|
||||
pub fn encode_batch_resource_limit_error_response(
|
||||
error: &ProtocolError,
|
||||
kind: ResourceLimitKind,
|
||||
limits: Limits,
|
||||
) -> Result<Response<Vec<u8>>, EncodeHttpError> {
|
||||
if error.code != ProtocolErrorCode::ResourceLimitExceeded {
|
||||
return Err(CodecError::InvalidField {
|
||||
field: "error.code".into(),
|
||||
reason: "resource-limit encoder requires resource_limit_exceeded".into(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
let body = json::serialize(&response::error_to_json(error), BodyKind::Response, limits)?;
|
||||
Ok(Response::builder()
|
||||
.status(response::status_for_resource_limit(kind))
|
||||
.header(CONTENT_TYPE, JSON_CONTENT_TYPE)
|
||||
.body(body)?)
|
||||
}
|
||||
|
||||
fn response_from_json(
|
||||
value: &Json,
|
||||
request: &BatchRequest,
|
||||
limits: Limits,
|
||||
) -> Result<BatchResponse, CodecError> {
|
||||
let fields = exact_object(value, "batch", &["responses"])?;
|
||||
let values = array(required(fields, "responses")?, "responses")?;
|
||||
if values.len() > limits.max_batch_items {
|
||||
return Err(CodecError::TooManyBatchItems {
|
||||
actual: values.len(),
|
||||
maximum: limits.max_batch_items,
|
||||
});
|
||||
}
|
||||
let requested: BTreeMap<&str, &BatchRequestKind> = request
|
||||
.requests
|
||||
.iter()
|
||||
.map(|item| (item.id.as_str(), &item.request))
|
||||
.collect();
|
||||
let mut responses = Vec::with_capacity(values.len());
|
||||
for (index, value) in values.iter().enumerate() {
|
||||
let field = format!("responses[{index}]");
|
||||
let fields = object(value, &field)?;
|
||||
for name in fields.keys() {
|
||||
if !["id", "result", "error"].contains(&name.as_str()) {
|
||||
return Err(CodecError::UnknownField {
|
||||
field: format!("{field}.{name}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
let id = string(required(fields, "id")?, &format!("{field}.id"))?;
|
||||
let Some(kind) = requested.get(id.as_str()) else {
|
||||
return Err(CodecError::UnexpectedBatchId { id });
|
||||
};
|
||||
let outcome = match (fields.get("result"), fields.get("error")) {
|
||||
(Some(_), Some(_)) | (None, None) => {
|
||||
return Err(CodecError::InvalidField {
|
||||
field,
|
||||
reason: "batch item must contain exactly one of result or error".into(),
|
||||
});
|
||||
}
|
||||
(None, Some(error)) => {
|
||||
let error = response::item_error_from_json(error)?;
|
||||
match kind {
|
||||
BatchRequestKind::Invalid {
|
||||
error: expected, ..
|
||||
} if error == *expected => {}
|
||||
BatchRequestKind::Invalid { .. } => {
|
||||
return Err(CodecError::BatchInteractionMismatch { id });
|
||||
}
|
||||
kind if error.code.is_allowed_for(kind.interaction()) => {}
|
||||
_ => return Err(CodecError::BatchInteractionMismatch { id }),
|
||||
}
|
||||
Err(error)
|
||||
}
|
||||
(Some(result), None) => Ok(match kind {
|
||||
BatchRequestKind::Fetch(request) => {
|
||||
let value = json::object_from_json(result, "result")?;
|
||||
request.validate_response(&value)?;
|
||||
BatchSuccess::Fetch(value)
|
||||
}
|
||||
BatchRequestKind::FetchTree(request) => {
|
||||
let value = json::fetch_tree_response_from_json(result)?;
|
||||
value.validate_for(request)?;
|
||||
BatchSuccess::FetchTree(value)
|
||||
}
|
||||
BatchRequestKind::FetchInterface(request) => {
|
||||
let value = json::fetch_interface_response_from_json(result)?;
|
||||
value.validate_for(request)?;
|
||||
BatchSuccess::FetchInterface(value)
|
||||
}
|
||||
BatchRequestKind::Invalid { .. } => {
|
||||
return Err(CodecError::BatchInteractionMismatch { id });
|
||||
}
|
||||
}),
|
||||
};
|
||||
responses.push(BatchResponseItem { id, outcome });
|
||||
}
|
||||
let response = BatchResponse { responses };
|
||||
correlate(request, &response, limits)?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Decodes a batch response, including item-local failures within HTTP 200.
|
||||
pub fn decode_batch_response<B: AsRef<[u8]>>(
|
||||
request: &BatchRequest,
|
||||
value: &Response<B>,
|
||||
limits: Limits,
|
||||
) -> Result<DecodedResponse<BatchResponse>, ClientResponseError> {
|
||||
let valid_content_type = value
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(is_json_content_type);
|
||||
if !valid_content_type {
|
||||
if value.status().is_success() {
|
||||
return Err(ClientResponseError::invalid(
|
||||
InvalidResponseKind::InvalidBatch,
|
||||
"missing or non-UTF-8 application/json Content-Type",
|
||||
));
|
||||
}
|
||||
return Err(response::transport_failure(value.status()));
|
||||
}
|
||||
let decoded = response::decode(
|
||||
value.status(),
|
||||
value.body().as_ref(),
|
||||
ProtocolInteraction::Fetch,
|
||||
limits,
|
||||
|json| response_from_json(json, request, limits),
|
||||
)
|
||||
.map_err(|error| match error {
|
||||
ClientResponseError::InvalidResponse { detail, .. } => {
|
||||
ClientResponseError::invalid(InvalidResponseKind::InvalidBatch, detail)
|
||||
}
|
||||
other => other,
|
||||
})?;
|
||||
if let DecodedResponse::ProtocolFailure { error, .. } = &decoded
|
||||
&& !matches!(
|
||||
error.code,
|
||||
ProtocolErrorCode::InvalidRequest
|
||||
| ProtocolErrorCode::ResourceLimitExceeded
|
||||
| ProtocolErrorCode::Internal
|
||||
)
|
||||
{
|
||||
return Err(ClientResponseError::invalid(
|
||||
InvalidResponseKind::InvalidBatch,
|
||||
"protocol code is not valid for a top-level batch failure",
|
||||
));
|
||||
}
|
||||
if let DecodedResponse::Success(success) = &decoded {
|
||||
for item in &success.responses {
|
||||
if let Ok(BatchSuccess::FetchInterface(interface)) = &item.outcome
|
||||
&& interface.descriptor.format != INTERFACE_FORMAT_V1
|
||||
{
|
||||
return Err(ClientResponseError::UnsupportedDescriptorFormat {
|
||||
format: interface.descriptor.format.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(decoded)
|
||||
}
|
||||
|
||||
fn object<'a>(value: &'a Json, field: &str) -> Result<&'a Map<String, Json>, CodecError> {
|
||||
value.as_object().ok_or_else(|| CodecError::InvalidField {
|
||||
field: field.into(),
|
||||
reason: "expected object".into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn exact_object<'a>(
|
||||
value: &'a Json,
|
||||
field: &str,
|
||||
allowed: &[&str],
|
||||
) -> Result<&'a Map<String, Json>, CodecError> {
|
||||
let fields = object(value, field)?;
|
||||
for name in fields.keys() {
|
||||
if !allowed.contains(&name.as_str()) {
|
||||
return Err(CodecError::UnknownField {
|
||||
field: format!("{field}.{name}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(fields)
|
||||
}
|
||||
|
||||
fn required<'a>(fields: &'a Map<String, Json>, name: &str) -> Result<&'a Json, CodecError> {
|
||||
fields
|
||||
.get(name)
|
||||
.ok_or_else(|| CodecError::MissingField { field: name.into() })
|
||||
}
|
||||
|
||||
fn string(value: &Json, field: &str) -> Result<String, CodecError> {
|
||||
value
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| CodecError::InvalidField {
|
||||
field: field.into(),
|
||||
reason: "expected string".into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn array<'a>(value: &'a Json, field: &str) -> Result<&'a [Json], CodecError> {
|
||||
value
|
||||
.as_array()
|
||||
.map(Vec::as_slice)
|
||||
.ok_or_else(|| CodecError::InvalidField {
|
||||
field: field.into(),
|
||||
reason: "expected array".into(),
|
||||
})
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
//! Framework-independent standard WIP over HTTPS v1 binding.
|
||||
//!
|
||||
//! This crate owns endpoint and route derivation, UTF-8 JSON encoding, canonical
|
||||
//! success and error response handling, HTTP status mapping, descriptor-guided
|
||||
//! operation-value conversion, and retrieval batching. It deliberately performs
|
||||
//! no I/O and depends on no server framework, TLS implementation, filesystem
|
||||
//! projection, client cache, or presentation layer.
|
||||
//!
|
||||
//! Logical values remain owned by [`wip_protocol`]. Validators and `Bytes` are
|
||||
//! encoded in JSON bodies using canonical RFC 4648 Section 4 base64; this crate
|
||||
//! does not infer WIP validator semantics from `ETag` or `If-Match`.
|
||||
//!
|
||||
//! ```
|
||||
//! use wip_https::{Endpoint, Limits, encode_fetch_request};
|
||||
//! use wip_protocol::FetchRequest;
|
||||
//!
|
||||
//! let endpoint = Endpoint::parse("https://example.test/api/wip")?;
|
||||
//! let limits = Limits::new(64 * 1024, 1024 * 1024, 64, 100)?;
|
||||
//! let request = encode_fetch_request(
|
||||
//! &endpoint,
|
||||
//! &FetchRequest { path: "/items/123".into() },
|
||||
//! limits,
|
||||
//! )?;
|
||||
//! assert_eq!(request.uri(), "https://example.test/api/wip/v1/fetch");
|
||||
//! assert_eq!(request.method(), http::Method::POST);
|
||||
//! # Ok::<(), Box<dyn std::error::Error>>(())
|
||||
//! ```
|
||||
|
||||
#![deny(missing_docs)]
|
||||
|
||||
mod batch;
|
||||
mod codec;
|
||||
mod endpoint;
|
||||
mod error;
|
||||
mod json;
|
||||
mod response;
|
||||
|
||||
pub use batch::{
|
||||
BatchMethod, BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse,
|
||||
BatchResponseItem, BatchSuccess, decode_batch_request, decode_batch_response,
|
||||
encode_batch_error_response, encode_batch_request, encode_batch_resource_limit_error_response,
|
||||
encode_batch_response,
|
||||
};
|
||||
pub use codec::{
|
||||
CallOperationMetadata, EncodeHttpError, RequestMetadataError, decode_call_operation_metadata,
|
||||
decode_call_operation_request, decode_call_operation_response, decode_fetch_interface_request,
|
||||
decode_fetch_interface_response, decode_fetch_request, decode_fetch_response,
|
||||
decode_fetch_tree_request, decode_fetch_tree_response, encode_call_operation_request,
|
||||
encode_call_operation_response, encode_fetch_interface_request,
|
||||
encode_fetch_interface_response, encode_fetch_request, encode_fetch_response,
|
||||
encode_fetch_tree_request, encode_fetch_tree_response, encode_protocol_error_response,
|
||||
encode_resource_limit_error_response, is_json_content_type, validate_http_request,
|
||||
};
|
||||
pub use endpoint::{Endpoint, EndpointError, JSON_CONTENT_TYPE, Route};
|
||||
pub use error::{BodyKind, CodecError, LimitConfigurationError, Limits, ResourceLimitKind};
|
||||
pub use response::{
|
||||
ClientResponseError, DecodedResponse, InvalidResponseKind, StatusMismatch,
|
||||
TransportBindingFailure, TransportFailureKind, error_code_name, status_for_error,
|
||||
status_for_resource_limit,
|
||||
};
|
||||
|
||||
/// Framework-neutral HTTP types used by this binding's public API.
|
||||
pub use http;
|
||||
/// URL type used by [`Endpoint`].
|
||||
pub use url;
|
||||
/// Transport-independent logical protocol model used by this binding.
|
||||
pub use wip_protocol;
|
||||
@@ -1,353 +0,0 @@
|
||||
use wip_https::http::StatusCode;
|
||||
use wip_https::wip_protocol::{
|
||||
FetchInterfaceRequest, FetchInterfaceResponse, FetchRequest, INTERFACE_FORMAT_V1,
|
||||
InterfaceDescriptor, Object, ProtocolError, ProtocolErrorCode,
|
||||
};
|
||||
use wip_https::{
|
||||
BatchMethod, BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse,
|
||||
BatchResponseItem, BatchSuccess, ClientResponseError, DecodedResponse, Endpoint,
|
||||
InvalidResponseKind, Limits, ResourceLimitKind, decode_batch_request, decode_batch_response,
|
||||
encode_batch_error_response, encode_batch_request, encode_batch_resource_limit_error_response,
|
||||
encode_batch_response,
|
||||
};
|
||||
|
||||
fn limits() -> Limits {
|
||||
Limits::new(100_000, 100_000, 64, 4).unwrap()
|
||||
}
|
||||
|
||||
fn request() -> BatchRequest {
|
||||
BatchRequest {
|
||||
requests: vec![
|
||||
BatchRequestItem {
|
||||
id: "object".into(),
|
||||
request: BatchRequestKind::Fetch(FetchRequest {
|
||||
path: "/item".into(),
|
||||
}),
|
||||
},
|
||||
BatchRequestItem {
|
||||
id: "interface".into(),
|
||||
request: BatchRequestKind::FetchInterface(FetchInterfaceRequest {
|
||||
interface: "interface-v1".into(),
|
||||
}),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_round_trips_by_unique_id_with_item_failures_and_validators() {
|
||||
let request = request();
|
||||
let endpoint = Endpoint::parse("https://example.test/wip").unwrap();
|
||||
let encoded_request = encode_batch_request(&endpoint, &request, limits()).unwrap();
|
||||
assert_eq!(encoded_request.uri().path(), "/wip/v1/batch");
|
||||
assert_eq!(encoded_request.headers()["accept"], "application/json");
|
||||
assert_eq!(
|
||||
decode_batch_request(encoded_request.body(), limits()).unwrap(),
|
||||
request
|
||||
);
|
||||
|
||||
let response = BatchResponse {
|
||||
responses: vec![
|
||||
BatchResponseItem {
|
||||
id: "interface".into(),
|
||||
outcome: Err(ProtocolError {
|
||||
code: ProtocolErrorCode::InterfaceNotFound,
|
||||
message: "gone".into(),
|
||||
}),
|
||||
},
|
||||
BatchResponseItem {
|
||||
id: "object".into(),
|
||||
outcome: Ok(BatchSuccess::Fetch(Object {
|
||||
name: "item".into(),
|
||||
description: None,
|
||||
interfaces: vec![],
|
||||
r#ref: None,
|
||||
validator: Some(vec![0, 1, 2, 255]),
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
let encoded = encode_batch_response(&request, &response, limits()).unwrap();
|
||||
assert_eq!(encoded.status(), StatusCode::OK);
|
||||
assert!(
|
||||
std::str::from_utf8(encoded.body())
|
||||
.unwrap()
|
||||
.contains("AAEC/w==")
|
||||
);
|
||||
assert_eq!(
|
||||
decode_batch_response(&request, &encoded, limits()).unwrap(),
|
||||
DecodedResponse::Success(response)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_interface_validator_round_trips_in_item_body() {
|
||||
let request = BatchRequest {
|
||||
requests: vec![BatchRequestItem {
|
||||
id: "interface".into(),
|
||||
request: BatchRequestKind::FetchInterface(FetchInterfaceRequest {
|
||||
interface: "interface-v1".into(),
|
||||
}),
|
||||
}],
|
||||
};
|
||||
let response = BatchResponse {
|
||||
responses: vec![BatchResponseItem {
|
||||
id: "interface".into(),
|
||||
outcome: Ok(BatchSuccess::FetchInterface(FetchInterfaceResponse {
|
||||
interface: "interface-v1".into(),
|
||||
descriptor: InterfaceDescriptor {
|
||||
format: INTERFACE_FORMAT_V1.into(),
|
||||
documentation: None,
|
||||
types: vec![],
|
||||
operations: vec![],
|
||||
},
|
||||
validator: Some(vec![255, 254, 253]),
|
||||
})),
|
||||
}],
|
||||
};
|
||||
let encoded = encode_batch_response(&request, &response, limits()).unwrap();
|
||||
assert!(
|
||||
std::str::from_utf8(encoded.body())
|
||||
.unwrap()
|
||||
.contains("//79")
|
||||
);
|
||||
assert_eq!(
|
||||
decode_batch_response(&request, &encoded, limits()).unwrap(),
|
||||
DecodedResponse::Success(response)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_known_method_arguments_are_item_local() {
|
||||
let body = br#"{
|
||||
"requests": [
|
||||
{"id":"valid","method":"fetch","arguments":{"path":"/item"}},
|
||||
{"id":"invalid","method":"fetch_tree","arguments":{"path":"/","depth":"deep","limit":1}}
|
||||
]
|
||||
}"#;
|
||||
let decoded = decode_batch_request(body, limits()).unwrap();
|
||||
assert!(matches!(
|
||||
decoded.requests[0].request,
|
||||
BatchRequestKind::Fetch(_)
|
||||
));
|
||||
let invalid = match &decoded.requests[1].request {
|
||||
BatchRequestKind::Invalid { method, error } => {
|
||||
assert_eq!(*method, BatchMethod::FetchTree);
|
||||
assert_eq!(error.code, ProtocolErrorCode::InvalidRequest);
|
||||
error.clone()
|
||||
}
|
||||
other => panic!("expected item-local invalid request, got {other:?}"),
|
||||
};
|
||||
|
||||
let response = BatchResponse {
|
||||
responses: vec![
|
||||
BatchResponseItem {
|
||||
id: "valid".into(),
|
||||
outcome: Ok(BatchSuccess::Fetch(Object {
|
||||
name: "item".into(),
|
||||
description: None,
|
||||
interfaces: vec![],
|
||||
r#ref: None,
|
||||
validator: None,
|
||||
})),
|
||||
},
|
||||
BatchResponseItem {
|
||||
id: "invalid".into(),
|
||||
outcome: Err(invalid),
|
||||
},
|
||||
],
|
||||
};
|
||||
let encoded = encode_batch_response(&decoded, &response, limits()).unwrap();
|
||||
assert_eq!(encoded.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
decode_batch_response(&decoded, &encoded, limits()).unwrap(),
|
||||
DecodedResponse::Success(response)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_batch_envelopes_are_rejected_at_top_level() {
|
||||
assert!(decode_batch_request(br#"{}"#, limits()).is_err());
|
||||
|
||||
let malformed_item = br#"{
|
||||
"requests": [
|
||||
{"id":"item","method":"fetch","arguments":{"path":"/"},"extra":true}
|
||||
]
|
||||
}"#;
|
||||
assert!(decode_batch_request(malformed_item, limits()).is_err());
|
||||
|
||||
let duplicate = br#"{
|
||||
"requests": [
|
||||
{"id":"same","method":"fetch","arguments":{"path":"/a"}},
|
||||
{"id":"same","method":"fetch","arguments":{"path":"/b"}}
|
||||
]
|
||||
}"#;
|
||||
assert!(decode_batch_request(duplicate, limits()).is_err());
|
||||
|
||||
let operation = br#"{
|
||||
"requests": [
|
||||
{"id":"call","method":"call_operation","arguments":{}}
|
||||
]
|
||||
}"#;
|
||||
assert!(decode_batch_request(operation, limits()).is_err());
|
||||
|
||||
let too_many = Limits::new(100_000, 100_000, 64, 1).unwrap();
|
||||
assert!(decode_batch_request(duplicate, too_many).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_resource_limits_use_413_or_422_and_never_429() {
|
||||
let error = ProtocolError {
|
||||
code: ProtocolErrorCode::ResourceLimitExceeded,
|
||||
message: "bounded".into(),
|
||||
};
|
||||
for (kind, status) in [
|
||||
(
|
||||
ResourceLimitKind::RepresentationBytes,
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
),
|
||||
(ResourceLimitKind::Logical, StatusCode::UNPROCESSABLE_ENTITY),
|
||||
] {
|
||||
let encoded = encode_batch_resource_limit_error_response(&error, kind, limits()).unwrap();
|
||||
assert_eq!(encoded.status(), status);
|
||||
assert_ne!(encoded.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
assert_eq!(
|
||||
decode_batch_response(&request(), &encoded, limits()).unwrap(),
|
||||
DecodedResponse::ProtocolFailure {
|
||||
error: error.clone(),
|
||||
status_mismatch: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_success_body_requires_exactly_http_200() {
|
||||
let request = request();
|
||||
let response = BatchResponse {
|
||||
responses: vec![
|
||||
BatchResponseItem {
|
||||
id: "object".into(),
|
||||
outcome: Ok(BatchSuccess::Fetch(Object {
|
||||
name: "item".into(),
|
||||
description: None,
|
||||
interfaces: vec![],
|
||||
r#ref: None,
|
||||
validator: None,
|
||||
})),
|
||||
},
|
||||
BatchResponseItem {
|
||||
id: "interface".into(),
|
||||
outcome: Err(ProtocolError {
|
||||
code: ProtocolErrorCode::InterfaceNotFound,
|
||||
message: "gone".into(),
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
let encoded = encode_batch_response(&request, &response, limits()).unwrap();
|
||||
for status in [StatusCode::CREATED, StatusCode::ACCEPTED] {
|
||||
let mut noncanonical = encoded.clone();
|
||||
*noncanonical.status_mut() = status;
|
||||
assert!(matches!(
|
||||
decode_batch_response(&request, &noncanonical, limits()).unwrap_err(),
|
||||
ClientResponseError::InvalidResponse { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_level_batch_errors_use_the_batch_allowed_code_set() {
|
||||
let invalid = ProtocolError {
|
||||
code: ProtocolErrorCode::InvalidRequest,
|
||||
message: "bad envelope".into(),
|
||||
};
|
||||
let encoded = encode_batch_error_response(&invalid, limits()).unwrap();
|
||||
assert_eq!(
|
||||
decode_batch_response(&request(), &encoded, limits()).unwrap(),
|
||||
DecodedResponse::ProtocolFailure {
|
||||
error: invalid,
|
||||
status_mismatch: None,
|
||||
}
|
||||
);
|
||||
|
||||
let not_found = wip_https::http::Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.header("content-type", "application/json; charset=utf-8")
|
||||
.body(br#"{"error":{"code":"not_found","message":"not a batch failure"}}"#.to_vec())
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
decode_batch_response(&request(), ¬_found, limits()).unwrap_err(),
|
||||
ClientResponseError::InvalidResponse {
|
||||
kind: InvalidResponseKind::InvalidBatch,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_batch_descriptor_waits_for_envelope_correlation() {
|
||||
let duplicate = wip_https::http::Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("content-type", "application/json; charset=utf-8")
|
||||
.body(
|
||||
br#"{"responses":[{"id":"interface","result":{"interface":"interface-v1","descriptor":{"format":"wip-interface/2","future":true}}},{"id":"interface","result":{"interface":"interface-v1","descriptor":{"format":"wip-interface/2"}}}]}"#
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
decode_batch_response(&request(), &duplicate, limits()).unwrap_err(),
|
||||
ClientResponseError::InvalidResponse {
|
||||
kind: InvalidResponseKind::InvalidBatch,
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
let missing = wip_https::http::Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("content-type", "application/json; charset=utf-8")
|
||||
.body(
|
||||
br#"{"responses":[{"id":"interface","result":{"interface":"interface-v1","descriptor":{"format":"wip-interface/2"}}}]}"#
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
decode_batch_response(&request(), &missing, limits()).unwrap_err(),
|
||||
ClientResponseError::InvalidResponse {
|
||||
kind: InvalidResponseKind::InvalidBatch,
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
let request = BatchRequest {
|
||||
requests: vec![BatchRequestItem {
|
||||
id: "interface".into(),
|
||||
request: BatchRequestKind::FetchInterface(FetchInterfaceRequest {
|
||||
interface: "interface-v1".into(),
|
||||
}),
|
||||
}],
|
||||
};
|
||||
assert!(matches!(
|
||||
decode_batch_response(&request, &missing, limits()).unwrap_err(),
|
||||
ClientResponseError::UnsupportedDescriptorFormat { format }
|
||||
if format == "wip-interface/2"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_response_must_have_exactly_the_requested_ids() {
|
||||
let request = request();
|
||||
let missing = BatchResponse {
|
||||
responses: vec![BatchResponseItem {
|
||||
id: "object".into(),
|
||||
outcome: Ok(BatchSuccess::Fetch(Object {
|
||||
name: "item".into(),
|
||||
description: None,
|
||||
interfaces: vec![],
|
||||
r#ref: None,
|
||||
validator: None,
|
||||
})),
|
||||
}],
|
||||
};
|
||||
assert!(encode_batch_response(&request, &missing, limits()).is_err());
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
use wip_https::http::{Method, Request, StatusCode};
|
||||
use wip_https::{Endpoint, RequestMetadataError, Route, validate_http_request};
|
||||
|
||||
#[test]
|
||||
fn endpoint_is_https_absolute_queryless_and_canonical() {
|
||||
let endpoint = Endpoint::parse("https://example.test/root/prefix").unwrap();
|
||||
assert_eq!(endpoint.to_string(), "https://example.test/root/prefix/");
|
||||
|
||||
let expected = [
|
||||
(Route::Fetch, "https://example.test/root/prefix/v1/fetch"),
|
||||
(
|
||||
Route::FetchTree,
|
||||
"https://example.test/root/prefix/v1/fetch_tree",
|
||||
),
|
||||
(
|
||||
Route::FetchInterface,
|
||||
"https://example.test/root/prefix/v1/fetch_interface",
|
||||
),
|
||||
(
|
||||
Route::CallOperation,
|
||||
"https://example.test/root/prefix/v1/call_operation",
|
||||
),
|
||||
(Route::Batch, "https://example.test/root/prefix/v1/batch"),
|
||||
];
|
||||
for (route, url) in expected {
|
||||
assert_eq!(endpoint.route(route).as_str(), url);
|
||||
assert_eq!(
|
||||
endpoint.recognize(&wip_https::url::Url::parse(url).unwrap()),
|
||||
Some(route)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
endpoint.recognize(
|
||||
&wip_https::url::Url::parse("https://example.test/root/prefix/v1/fetch?path=/items")
|
||||
.unwrap()
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routes_never_mix_worldspace_paths_into_endpoint_prefixes() {
|
||||
let endpoint = Endpoint::parse("https://example.test/wip/%E4%B8%96%E7%95%8C/").unwrap();
|
||||
let route = endpoint.route(Route::Fetch);
|
||||
assert_eq!(route.path(), "/wip/%E4%B8%96%E7%95%8C/v1/fetch");
|
||||
assert!(!route.as_str().contains("items"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inbound_http_metadata_enforces_post_route_and_json() {
|
||||
let endpoint = Endpoint::parse("https://example.test/wip").unwrap();
|
||||
let valid = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/wip/v1/fetch")
|
||||
.header("content-type", "application/json; charset=utf-8")
|
||||
.body(Vec::<u8>::new())
|
||||
.unwrap();
|
||||
assert!(validate_http_request(&endpoint, Route::Fetch, &valid).is_ok());
|
||||
|
||||
let wrong_method = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/wip/v1/fetch")
|
||||
.header("content-type", "application/json")
|
||||
.body(Vec::<u8>::new())
|
||||
.unwrap();
|
||||
assert!(validate_http_request(&endpoint, Route::Fetch, &wrong_method).is_err());
|
||||
|
||||
let wrong_route = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/wip/v1/fetch_tree")
|
||||
.header("content-type", "application/json")
|
||||
.body(Vec::<u8>::new())
|
||||
.unwrap();
|
||||
assert!(validate_http_request(&endpoint, Route::Fetch, &wrong_route).is_err());
|
||||
|
||||
for accept in [
|
||||
"text/plain, */*;q=1, application/json;q=0",
|
||||
"application/json; charset=utf-16",
|
||||
"application/json; profile=future",
|
||||
] {
|
||||
let incompatible_accept = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/wip/v1/fetch")
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", accept)
|
||||
.body(Vec::<u8>::new())
|
||||
.unwrap();
|
||||
let error =
|
||||
validate_http_request(&endpoint, Route::Fetch, &incompatible_accept).unwrap_err();
|
||||
assert!(matches!(&error, RequestMetadataError::NotAcceptable));
|
||||
assert_eq!(error.status(), StatusCode::NOT_ACCEPTABLE);
|
||||
}
|
||||
|
||||
for accept in [
|
||||
"application/json; charset=UTF-8",
|
||||
"application/json; q=1; profile=accept-extension",
|
||||
] {
|
||||
let compatible_accept = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/wip/v1/fetch")
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", accept)
|
||||
.body(Vec::<u8>::new())
|
||||
.unwrap();
|
||||
assert!(validate_http_request(&endpoint, Route::Fetch, &compatible_accept).is_ok());
|
||||
}
|
||||
|
||||
let unsupported_media_type = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/wip/v1/fetch")
|
||||
.header("content-type", "text/plain")
|
||||
.header("accept", "application/json")
|
||||
.body(Vec::<u8>::new())
|
||||
.unwrap();
|
||||
let error =
|
||||
validate_http_request(&endpoint, Route::Fetch, &unsupported_media_type).unwrap_err();
|
||||
assert!(matches!(&error, RequestMetadataError::UnsupportedMediaType));
|
||||
assert_eq!(error.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_endpoint_components_are_rejected() {
|
||||
assert!(Endpoint::parse("http://example.test/wip").is_err());
|
||||
assert!(Endpoint::parse("/relative").is_err());
|
||||
assert!(Endpoint::parse("https://example.test/wip?q=1").is_err());
|
||||
assert!(Endpoint::parse("https://example.test/wip#fragment").is_err());
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
[package]
|
||||
name = "wip-interface-macros"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
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]
|
||||
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]
|
||||
name = "wip-interface"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
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]
|
||||
wip-interface-macros = { path = "../wip-interface-macros" }
|
||||
wip-protocol = { path = "../wip-protocol" }
|
||||
wip-interface-macros.workspace = true
|
||||
wip-protocol.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
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]
|
||||
name = "wip-protocol"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
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]
|
||||
|
||||
@@ -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.
|
||||
@@ -62,7 +62,7 @@ pub enum PathSegment {
|
||||
Return,
|
||||
/// Object at a Worldspace path.
|
||||
Object(String),
|
||||
/// Child object in a tree response.
|
||||
/// Child object in an observation response.
|
||||
Child(String),
|
||||
}
|
||||
|
||||
@@ -143,8 +143,17 @@ pub enum ValidationErrorKind {
|
||||
},
|
||||
/// An object's ordered interface set contains a duplicate.
|
||||
DuplicateInterface {
|
||||
/// Duplicate opaque interface reference.
|
||||
reference: String,
|
||||
/// Duplicate structured interface reference.
|
||||
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.
|
||||
InvalidPath {
|
||||
@@ -163,24 +172,22 @@ pub enum ValidationErrorKind {
|
||||
/// Shared opaque object reference.
|
||||
reference: String,
|
||||
},
|
||||
/// A `fetch_tree` request used a zero limit.
|
||||
ZeroTreeLimit,
|
||||
/// A tree response exceeds the requested maximum depth.
|
||||
TreeDepthExceeded {
|
||||
/// Requested maximum depth.
|
||||
maximum: u32,
|
||||
/// An observation omitted children before reaching the requested depth.
|
||||
ChildrenMissingBeforeDepth {
|
||||
/// Node depth where children were required.
|
||||
depth: u32,
|
||||
},
|
||||
/// A tree response exceeds the requested maximum node count.
|
||||
TreeLimitExceeded {
|
||||
/// Requested maximum node count.
|
||||
limit: u32,
|
||||
/// An observation included children at the requested depth boundary.
|
||||
ChildrenAtDepthBoundary {
|
||||
/// Boundary depth where children must be unobserved.
|
||||
depth: u32,
|
||||
},
|
||||
/// A response refers to a different interface than its request.
|
||||
InterfaceResponseMismatch {
|
||||
/// Requested opaque reference.
|
||||
expected: String,
|
||||
/// Returned opaque reference.
|
||||
actual: String,
|
||||
/// Requested structured reference (boxed to keep validation errors small).
|
||||
expected: Box<crate::InterfaceReference>,
|
||||
/// Returned structured reference (boxed to keep validation errors small).
|
||||
actual: Box<crate::InterfaceReference>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -226,11 +233,9 @@ impl Error for ValidationError {}
|
||||
/// One core interaction whose failure codes form a closed set.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProtocolInteraction {
|
||||
/// Fetch one object by its Worldspace path.
|
||||
Fetch,
|
||||
/// Fetch a bounded tree of objects.
|
||||
FetchTree,
|
||||
/// Fetch one interface descriptor by its opaque reference.
|
||||
/// Observe one object and its indexable descendants to a requested depth.
|
||||
Observe,
|
||||
/// Fetch one interface descriptor by its structured reference.
|
||||
FetchInterface,
|
||||
/// Invoke one operation on a path-targeted object.
|
||||
CallOperation,
|
||||
@@ -315,11 +320,11 @@ impl ProtocolErrorCode {
|
||||
OperationNotFound, OperationOutcomeUnknown, PermissionDenied, ResourceLimitExceeded,
|
||||
ValidatorMismatch, ValidatorRequired,
|
||||
};
|
||||
use ProtocolInteraction::{CallOperation, Fetch, FetchInterface, FetchTree};
|
||||
use ProtocolInteraction::{CallOperation, FetchInterface, Observe};
|
||||
|
||||
match (interaction, self) {
|
||||
(_, InvalidRequest | ResourceLimitExceeded | Internal) => true,
|
||||
(Fetch | FetchTree, NotFound) => true,
|
||||
(Observe, NotFound) => true,
|
||||
(FetchInterface, InterfaceNotFound) => true,
|
||||
(
|
||||
CallOperation,
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
//! | Interface Descriptor | [`InterfaceDescriptor`] |
|
||||
//! | Named declaration and all schema expressions | [`TypeDeclaration`] / [`TypeExpr`] |
|
||||
//! | Schema-neutral primitive/composite value | [`Value`] |
|
||||
//! | `fetch` | [`FetchRequest`] / [`FetchResponse`] |
|
||||
//! | `fetch_tree` | [`FetchTreeRequest`] / [`FetchTreeResponse`] |
|
||||
//! | `observe` | [`ObserveRequest`] / [`ObserveResponse`] |
|
||||
//! | `fetch_interface` | [`FetchInterfaceRequest`] / [`FetchInterfaceResponse`] |
|
||||
//! | Object and interface call preconditions | [`Target`] / [`InterfaceTarget`] |
|
||||
//! | `call_operation` | [`CallOperationRequest`] / [`CallOperationResponse`] |
|
||||
@@ -32,7 +31,7 @@
|
||||
//! guarantee; Clients must not automatically retry it.
|
||||
//!
|
||||
//! 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
|
||||
//! path, and this crate defines no interaction that fetches an object by ref.
|
||||
//!
|
||||
@@ -53,8 +52,8 @@ pub use error::{
|
||||
};
|
||||
pub use model::{
|
||||
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
|
||||
FetchInterfaceResponse, FetchRequest, FetchResponse, FetchTreeRequest, FetchTreeResponse,
|
||||
FieldDeclaration, InterfaceDescriptor, InterfaceTarget, Object, ObjectTreeNode,
|
||||
FetchInterfaceResponse, FieldDeclaration, InterfaceDescriptor, InterfaceReference,
|
||||
InterfaceTarget, Object, ObjectObservation, ObserveRequest, ObserveResponse,
|
||||
OperationDeclaration, ParameterDeclaration, ProtocolResult, ReturnDeclaration, Target,
|
||||
TypeDeclaration, TypeExpr, UnionCase, Value,
|
||||
};
|
||||
|
||||
@@ -11,9 +11,49 @@ pub struct Documentation {
|
||||
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.
|
||||
///
|
||||
/// `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
|
||||
/// independent optional observation precondition. In particular, neither the
|
||||
/// path nor `r#ref` is a mandatory object identifier.
|
||||
@@ -23,8 +63,8 @@ pub struct Object {
|
||||
pub name: String,
|
||||
/// Short public description of the object.
|
||||
pub description: Option<String>,
|
||||
/// Opaque interface references in host publication order.
|
||||
pub interfaces: Vec<String>,
|
||||
/// Structured interface references in host publication order.
|
||||
pub interfaces: Vec<InterfaceReference>,
|
||||
/// Optional opaque identity within one client-facing Worldspace.
|
||||
pub r#ref: Option<String>,
|
||||
/// Optional opaque validator for the object's observed state.
|
||||
@@ -218,57 +258,41 @@ impl Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// Request for the object currently published at one path.
|
||||
/// Request for the object currently published at one path and its indexable descendants.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FetchRequest {
|
||||
/// Canonical absolute Worldspace path.
|
||||
pub struct ObserveRequest {
|
||||
/// Canonical absolute path of the observation root.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Successful `fetch` response.
|
||||
pub type FetchResponse = Object;
|
||||
|
||||
/// Request for a bounded breadth-first object-tree observation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FetchTreeRequest {
|
||||
/// Canonical absolute path of the tree root.
|
||||
pub path: String,
|
||||
/// Maximum depth, where the requested object has depth zero.
|
||||
/// Maximum observed depth, where the requested object has depth zero.
|
||||
pub depth: u32,
|
||||
/// Maximum total node count, including the root. Must be nonzero.
|
||||
pub limit: u32,
|
||||
}
|
||||
|
||||
/// One node in a tree observation.
|
||||
/// One node in an object observation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ObjectTreeNode {
|
||||
/// Object observed at this tree position.
|
||||
pub struct ObjectObservation {
|
||||
/// Object observed at this Worldspace position.
|
||||
pub object: Object,
|
||||
/// Direct children in host-defined traversal order.
|
||||
pub children: Vec<ObjectTreeNode>,
|
||||
/// Complete direct children when observed, or `None` at the requested depth boundary.
|
||||
pub children: Option<Vec<ObjectObservation>>,
|
||||
}
|
||||
|
||||
/// Successful `fetch_tree` response.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FetchTreeResponse {
|
||||
/// Observed tree rooted at the requested path.
|
||||
pub root: ObjectTreeNode,
|
||||
/// Whether public indexable children were omitted by depth or limit.
|
||||
pub truncated: bool,
|
||||
}
|
||||
/// Successful `observe` response.
|
||||
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)]
|
||||
pub struct FetchInterfaceRequest {
|
||||
/// Opaque interface reference obtained from an object.
|
||||
pub interface: String,
|
||||
/// Structured interface reference obtained from an object.
|
||||
pub interface: InterfaceReference,
|
||||
}
|
||||
|
||||
/// Successful `fetch_interface` response.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FetchInterfaceResponse {
|
||||
/// Opaque interface reference corresponding to the request.
|
||||
pub interface: String,
|
||||
/// Structured interface reference corresponding to the request.
|
||||
pub interface: InterfaceReference,
|
||||
/// Optional opaque ref of the scope Object, not part of interface identity.
|
||||
pub scope_ref: Option<String>,
|
||||
/// Current validated descriptor.
|
||||
pub descriptor: InterfaceDescriptor,
|
||||
/// Optional opaque validator for this descriptor observation.
|
||||
@@ -287,8 +311,10 @@ pub struct Target {
|
||||
/// Interface portion of an operation target.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InterfaceTarget {
|
||||
/// Opaque interface reference selected from the object's interface set.
|
||||
pub reference: String,
|
||||
/// Structured reference selected from the object's interface set.
|
||||
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.
|
||||
pub validator: Option<Vec<u8>>,
|
||||
}
|
||||
@@ -372,35 +398,25 @@ impl InterfaceDescriptor {
|
||||
}
|
||||
}
|
||||
|
||||
impl FetchRequest {
|
||||
impl ObserveRequest {
|
||||
/// Validates the request path.
|
||||
pub fn validate(&self) -> Result<(), ValidationError> {
|
||||
crate::validation::validate_path(&self.path)
|
||||
}
|
||||
|
||||
/// Validates an object returned for this request.
|
||||
/// Validates observation shape, names, and requested depth.
|
||||
///
|
||||
/// Failure means the Client received an invalid success response; it is not a
|
||||
/// Host-returned [`crate::ProtocolErrorCode`].
|
||||
pub fn validate_response(&self, response: &FetchResponse) -> Result<(), ValidationError> {
|
||||
response.validate_at_path(&self.path)
|
||||
pub fn validate_response(&self, response: &ObserveResponse) -> Result<(), ValidationError> {
|
||||
crate::validation::validate_observe_response(response, self)
|
||||
}
|
||||
}
|
||||
|
||||
impl FetchTreeRequest {
|
||||
/// Validates the request path and nonzero node limit.
|
||||
impl FetchInterfaceRequest {
|
||||
/// Validates reference shape; fetching an interface needs no target path.
|
||||
pub fn validate(&self) -> Result<(), ValidationError> {
|
||||
crate::validation::validate_fetch_tree_request(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl FetchTreeResponse {
|
||||
/// Validates tree shape, names, depth, and node limit for `request`.
|
||||
///
|
||||
/// Failure means the Client received an invalid success response; it is not a
|
||||
/// Host-returned [`crate::ProtocolErrorCode`].
|
||||
pub fn validate_for(&self, request: &FetchTreeRequest) -> Result<(), ValidationError> {
|
||||
crate::validation::validate_fetch_tree_response(self, request)
|
||||
self.interface.validate()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,7 +433,16 @@ impl FetchInterfaceResponse {
|
||||
}
|
||||
|
||||
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> {
|
||||
descriptor.validate_call(self)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use crate::{
|
||||
CallOperationRequest, FetchInterfaceRequest, FetchInterfaceResponse, FetchTreeRequest,
|
||||
FetchTreeResponse, InterfaceDescriptor, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, NameNamespace,
|
||||
Object, ObjectTreeNode, PathSegment, TypeDeclaration, TypeExpr, UNION_CASE_FIELD,
|
||||
CallOperationRequest, FetchInterfaceRequest, FetchInterfaceResponse, InterfaceDescriptor,
|
||||
InterfaceReference, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, NameNamespace, Object,
|
||||
ObjectObservation, ObserveRequest, PathSegment, TypeDeclaration, TypeExpr, UNION_CASE_FIELD,
|
||||
UNION_VALUE_FIELD, ValidationError, ValidationErrorKind, Value, ValueKind,
|
||||
};
|
||||
|
||||
@@ -11,8 +11,13 @@ pub(crate) fn validate_object(object: &Object) -> Result<(), ValidationError> {
|
||||
validate_object_name(&object.name, true)?;
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
for interface in &object.interfaces {
|
||||
if !seen.insert(interface.as_str()) {
|
||||
for (index, interface) in object.interfaces.iter().enumerate() {
|
||||
interface.validate().map_err(|error| {
|
||||
error
|
||||
.prepend(PathSegment::Index(index))
|
||||
.prepend(PathSegment::Field("interfaces".into()))
|
||||
})?;
|
||||
if !seen.insert(interface) {
|
||||
return Err(
|
||||
ValidationError::new(ValidationErrorKind::DuplicateInterface {
|
||||
reference: interface.clone(),
|
||||
@@ -51,6 +56,14 @@ pub(crate) fn validate_object_at_path(object: &Object, path: &str) -> Result<(),
|
||||
.next()
|
||||
.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 {
|
||||
Ok(())
|
||||
} 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> {
|
||||
let declarations = declaration_map(descriptor)?;
|
||||
|
||||
@@ -582,8 +643,7 @@ pub(crate) fn validate_call(
|
||||
descriptor: &InterfaceDescriptor,
|
||||
request: &CallOperationRequest,
|
||||
) -> Result<(), ValidationError> {
|
||||
validate_path(&request.target.path)
|
||||
.map_err(|error| error.prepend(PathSegment::Field("target".into())))?;
|
||||
request.validate()?;
|
||||
validate_arguments(descriptor, &request.operation, &request.arguments)
|
||||
}
|
||||
|
||||
@@ -604,60 +664,38 @@ fn find_operation<'a>(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn validate_fetch_tree_request(
|
||||
request: &FetchTreeRequest,
|
||||
) -> Result<(), ValidationError> {
|
||||
validate_path(&request.path)?;
|
||||
if request.limit == 0 {
|
||||
Err(ValidationError::new(ValidationErrorKind::ZeroTreeLimit))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_fetch_tree_response(
|
||||
response: &FetchTreeResponse,
|
||||
request: &FetchTreeRequest,
|
||||
pub(crate) fn validate_observe_response(
|
||||
response: &ObjectObservation,
|
||||
request: &ObserveRequest,
|
||||
) -> Result<(), ValidationError> {
|
||||
request.validate()?;
|
||||
let mut node_count = 0_u32;
|
||||
validate_tree_node(
|
||||
&response.root,
|
||||
&request.path,
|
||||
0,
|
||||
request.depth,
|
||||
request.limit,
|
||||
&mut node_count,
|
||||
)
|
||||
validate_observation_node(response, &request.path, 0, request.depth)
|
||||
}
|
||||
|
||||
fn validate_tree_node(
|
||||
node: &ObjectTreeNode,
|
||||
fn validate_observation_node(
|
||||
node: &ObjectObservation,
|
||||
path: &str,
|
||||
depth: u32,
|
||||
maximum_depth: u32,
|
||||
limit: u32,
|
||||
node_count: &mut u32,
|
||||
) -> Result<(), ValidationError> {
|
||||
if depth > maximum_depth {
|
||||
return Err(
|
||||
ValidationError::new(ValidationErrorKind::TreeDepthExceeded {
|
||||
maximum: maximum_depth,
|
||||
})
|
||||
.prepend(PathSegment::Object(path.to_owned())),
|
||||
);
|
||||
}
|
||||
*node_count = node_count.saturating_add(1);
|
||||
if *node_count > limit {
|
||||
return Err(
|
||||
ValidationError::new(ValidationErrorKind::TreeLimitExceeded { limit })
|
||||
.prepend(PathSegment::Object(path.to_owned())),
|
||||
);
|
||||
node.object.validate_at_path(path)?;
|
||||
|
||||
if depth == maximum_depth {
|
||||
if node.children.is_some() {
|
||||
return Err(
|
||||
ValidationError::new(ValidationErrorKind::ChildrenAtDepthBoundary { depth })
|
||||
.prepend(PathSegment::Object(path.to_owned())),
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
node.object.validate_at_path(path)?;
|
||||
let children = node.children.as_ref().ok_or_else(|| {
|
||||
ValidationError::new(ValidationErrorKind::ChildrenMissingBeforeDepth { depth })
|
||||
.prepend(PathSegment::Object(path.to_owned()))
|
||||
})?;
|
||||
let mut names = HashSet::new();
|
||||
for child in &node.children {
|
||||
for child in children {
|
||||
validate_object_name(&child.object.name, false)
|
||||
.map_err(|error| error.prepend(PathSegment::Child(child.object.name.clone())))?;
|
||||
if !names.insert(child.object.name.as_str()) {
|
||||
@@ -673,14 +711,7 @@ fn validate_tree_node(
|
||||
} else {
|
||||
format!("{path}/{}", child.object.name)
|
||||
};
|
||||
validate_tree_node(
|
||||
child,
|
||||
&child_path,
|
||||
depth.saturating_add(1),
|
||||
maximum_depth,
|
||||
limit,
|
||||
node_count,
|
||||
)?;
|
||||
validate_observation_node(child, &child_path, depth + 1, maximum_depth)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -689,11 +720,13 @@ pub(crate) fn validate_fetch_interface_response(
|
||||
response: &FetchInterfaceResponse,
|
||||
request: &FetchInterfaceRequest,
|
||||
) -> Result<(), ValidationError> {
|
||||
request.validate()?;
|
||||
response.interface.validate()?;
|
||||
if response.interface != request.interface {
|
||||
return Err(
|
||||
ValidationError::new(ValidationErrorKind::InterfaceResponseMismatch {
|
||||
expected: request.interface.clone(),
|
||||
actual: response.interface.clone(),
|
||||
expected: Box::new(request.interface.clone()),
|
||||
actual: Box::new(response.interface.clone()),
|
||||
})
|
||||
.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
|
||||
);
|
||||
}
|
||||
@@ -2,13 +2,20 @@ use std::collections::BTreeMap;
|
||||
|
||||
use wip_protocol::{
|
||||
CallOperationRequest, CallOperationResponse, EnumCase, FetchInterfaceRequest,
|
||||
FetchInterfaceResponse, FetchRequest, FetchTreeRequest, FetchTreeResponse, FieldDeclaration,
|
||||
INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceTarget, MAX_SAFE_INTEGER, NameNamespace,
|
||||
Object, ObjectTreeNode, OperationDeclaration, ParameterDeclaration, PathSegment, ProtocolError,
|
||||
ProtocolErrorCode, ProtocolInteraction, ProtocolResult, ReturnDeclaration, Target,
|
||||
TypeDeclaration, TypeExpr, UnionCase, ValidationErrorKind, Value, ValueKind,
|
||||
FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor,
|
||||
InterfaceReference, InterfaceTarget, MAX_SAFE_INTEGER, NameNamespace, Object,
|
||||
ObjectObservation, ObserveRequest, OperationDeclaration, ParameterDeclaration, PathSegment,
|
||||
ProtocolError, ProtocolErrorCode, ProtocolInteraction, ProtocolResult, ReturnDeclaration,
|
||||
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 {
|
||||
Value::Record(
|
||||
entries
|
||||
@@ -135,7 +142,7 @@ fn object(name: &str) -> Object {
|
||||
Object {
|
||||
name: name.into(),
|
||||
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()),
|
||||
validator: Some(vec![1, 2, 3]),
|
||||
}
|
||||
@@ -146,16 +153,19 @@ fn object_fields_have_canonical_identity_and_ordered_set_semantics() {
|
||||
let object = object("article");
|
||||
object.validate().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 {
|
||||
interfaces: vec!["catalog-v1".into(), "catalog-v1".into()],
|
||||
interfaces: vec![reference("/", "catalog-v1"), reference("/", "catalog-v1")],
|
||||
..object.clone()
|
||||
};
|
||||
assert_eq!(
|
||||
duplicate.validate().unwrap_err().kind,
|
||||
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]),
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: "catalog-v1".into(),
|
||||
scope_ref: None,
|
||||
reference: reference("/", "catalog-v1"),
|
||||
validator: Some(vec![2]),
|
||||
},
|
||||
operation: "publish".into(),
|
||||
@@ -617,13 +628,26 @@ fn calls_validate_canonical_paths_named_arguments_and_unit_results() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_and_fetch_interface_dtos_validate_request_response_correspondence() {
|
||||
let fetch = FetchRequest {
|
||||
fn observe_and_fetch_interface_dtos_validate_request_response_correspondence() {
|
||||
let observe = ObserveRequest {
|
||||
path: "/articles/one".into(),
|
||||
depth: 0,
|
||||
};
|
||||
fetch.validate().unwrap();
|
||||
fetch.validate_response(&object("one")).unwrap();
|
||||
assert!(fetch.validate_response(&object("two")).is_err());
|
||||
observe.validate().unwrap();
|
||||
observe
|
||||
.validate_response(&ObjectObservation {
|
||||
object: object("one"),
|
||||
children: None,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
observe
|
||||
.validate_response(&ObjectObservation {
|
||||
object: object("two"),
|
||||
children: None,
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
|
||||
for invalid in [
|
||||
"",
|
||||
@@ -634,8 +658,9 @@ fn fetch_and_fetch_interface_dtos_validate_request_response_correspondence() {
|
||||
"/a/../b",
|
||||
] {
|
||||
assert!(matches!(
|
||||
FetchRequest {
|
||||
path: invalid.into()
|
||||
ObserveRequest {
|
||||
path: invalid.into(),
|
||||
depth: 0,
|
||||
}
|
||||
.validate()
|
||||
.unwrap_err()
|
||||
@@ -645,17 +670,18 @@ fn fetch_and_fetch_interface_dtos_validate_request_response_correspondence() {
|
||||
}
|
||||
|
||||
let request = FetchInterfaceRequest {
|
||||
interface: "catalog-v1".into(),
|
||||
interface: reference("/", "catalog-v1"),
|
||||
};
|
||||
let response = FetchInterfaceResponse {
|
||||
interface: "catalog-v1".into(),
|
||||
scope_ref: None,
|
||||
interface: reference("/", "catalog-v1"),
|
||||
descriptor: descriptor(),
|
||||
validator: Some(vec![9]),
|
||||
};
|
||||
response.validate_for(&request).unwrap();
|
||||
assert!(matches!(
|
||||
FetchInterfaceResponse {
|
||||
interface: "other".into(),
|
||||
interface: reference("/", "other"),
|
||||
..response
|
||||
}
|
||||
.validate_for(&request)
|
||||
@@ -666,61 +692,51 @@ fn fetch_and_fetch_interface_dtos_validate_request_response_correspondence() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_tree_dtos_validate_names_depth_limit_and_siblings() {
|
||||
let request = FetchTreeRequest {
|
||||
fn observe_response_validates_complete_children_and_depth_boundary() {
|
||||
let request = ObserveRequest {
|
||||
path: "/articles".into(),
|
||||
depth: 1,
|
||||
limit: 3,
|
||||
};
|
||||
let leaf = |name: &str| ObjectTreeNode {
|
||||
let leaf = |name: &str| ObjectObservation {
|
||||
object: object(name),
|
||||
children: vec![],
|
||||
children: None,
|
||||
};
|
||||
let response = FetchTreeResponse {
|
||||
root: ObjectTreeNode {
|
||||
object: object("articles"),
|
||||
children: vec![leaf("one"), leaf("two")],
|
||||
},
|
||||
truncated: false,
|
||||
let response = ObjectObservation {
|
||||
object: object("articles"),
|
||||
children: Some(vec![leaf("one"), leaf("two")]),
|
||||
};
|
||||
response.validate_for(&request).unwrap();
|
||||
request.validate_response(&response).unwrap();
|
||||
|
||||
let zero = FetchTreeRequest {
|
||||
limit: 0,
|
||||
..request.clone()
|
||||
let empty = ObjectObservation {
|
||||
object: object("articles"),
|
||||
children: Some(vec![]),
|
||||
};
|
||||
assert_eq!(
|
||||
zero.validate().unwrap_err().kind,
|
||||
ValidationErrorKind::ZeroTreeLimit
|
||||
);
|
||||
request.validate_response(&empty).unwrap();
|
||||
|
||||
let shallow = FetchTreeRequest {
|
||||
let missing = ObjectObservation {
|
||||
object: object("articles"),
|
||||
children: None,
|
||||
};
|
||||
assert!(matches!(
|
||||
request.validate_response(&missing).unwrap_err().kind,
|
||||
ValidationErrorKind::ChildrenMissingBeforeDepth { depth: 0 }
|
||||
));
|
||||
|
||||
let boundary = ObserveRequest {
|
||||
depth: 0,
|
||||
..request.clone()
|
||||
};
|
||||
assert!(matches!(
|
||||
response.validate_for(&shallow).unwrap_err().kind,
|
||||
ValidationErrorKind::TreeDepthExceeded { .. }
|
||||
boundary.validate_response(&response).unwrap_err().kind,
|
||||
ValidationErrorKind::ChildrenAtDepthBoundary { depth: 0 }
|
||||
));
|
||||
|
||||
let small = FetchTreeRequest {
|
||||
limit: 2,
|
||||
..request.clone()
|
||||
let duplicate = ObjectObservation {
|
||||
object: object("articles"),
|
||||
children: Some(vec![leaf("one"), leaf("one")]),
|
||||
};
|
||||
assert!(matches!(
|
||||
response.validate_for(&small).unwrap_err().kind,
|
||||
ValidationErrorKind::TreeLimitExceeded { .. }
|
||||
));
|
||||
|
||||
let duplicate = FetchTreeResponse {
|
||||
root: ObjectTreeNode {
|
||||
object: object("articles"),
|
||||
children: vec![leaf("one"), leaf("one")],
|
||||
},
|
||||
truncated: false,
|
||||
};
|
||||
assert!(matches!(
|
||||
duplicate.validate_for(&request).unwrap_err().kind,
|
||||
request.validate_response(&duplicate).unwrap_err().kind,
|
||||
ValidationErrorKind::DuplicateName {
|
||||
namespace: NameNamespace::ChildObject,
|
||||
..
|
||||
@@ -778,22 +794,9 @@ fn assert_allowed_codes(interaction: ProtocolInteraction, expected: &[ProtocolEr
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_error_codes_are_exhaustive() {
|
||||
fn observe_error_codes_are_exhaustive() {
|
||||
assert_allowed_codes(
|
||||
ProtocolInteraction::Fetch,
|
||||
&[
|
||||
ProtocolErrorCode::InvalidRequest,
|
||||
ProtocolErrorCode::NotFound,
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
ProtocolErrorCode::Internal,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_tree_error_codes_are_exhaustive() {
|
||||
assert_allowed_codes(
|
||||
ProtocolInteraction::FetchTree,
|
||||
ProtocolInteraction::Observe,
|
||||
&[
|
||||
ProtocolErrorCode::InvalidRequest,
|
||||
ProtocolErrorCode::NotFound,
|
||||
@@ -840,16 +843,23 @@ fn call_operation_error_codes_are_exhaustive() {
|
||||
|
||||
#[test]
|
||||
fn invalid_success_payloads_remain_client_local_validation_failures() {
|
||||
let fetch = FetchRequest {
|
||||
let observe = ObserveRequest {
|
||||
path: "/articles/one".into(),
|
||||
depth: 0,
|
||||
};
|
||||
assert!(matches!(
|
||||
fetch.validate_response(&object("other")).unwrap_err().kind,
|
||||
observe
|
||||
.validate_response(&ObjectObservation {
|
||||
object: object("other"),
|
||||
children: None,
|
||||
})
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ValidationErrorKind::ObjectNameMismatch { .. }
|
||||
));
|
||||
|
||||
let interface_request = FetchInterfaceRequest {
|
||||
interface: "catalog-v1".into(),
|
||||
interface: reference("/", "catalog-v1"),
|
||||
};
|
||||
let mut invalid_descriptor = descriptor();
|
||||
invalid_descriptor
|
||||
@@ -858,6 +868,7 @@ fn invalid_success_payloads_remain_client_local_validation_failures() {
|
||||
assert!(matches!(
|
||||
FetchInterfaceResponse {
|
||||
interface: interface_request.interface.clone(),
|
||||
scope_ref: None,
|
||||
descriptor: invalid_descriptor,
|
||||
validator: None,
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
[package]
|
||||
name = "wip-public-api-example"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
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
|
||||
|
||||
[dependencies]
|
||||
wip-host = { path = "../wip-host" }
|
||||
wip-interface = { path = "../wip-interface" }
|
||||
wip-protocol = { path = "../wip-protocol" }
|
||||
wip-host.workspace = true
|
||||
wip-interface.workspace = true
|
||||
wip-protocol.workspace = true
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
//!
|
||||
//! The canonical API targets operations by `target.path` rather than a mandatory
|
||||
//! 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
|
||||
//! name nor an opaque-reference `name` field. Application records, enums,
|
||||
//! 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::WipType;
|
||||
use wip_protocol::{ProtocolError, ProtocolErrorCode, Value};
|
||||
use wip_protocol::{InterfaceReference, ProtocolError, ProtocolErrorCode, Value};
|
||||
|
||||
const OBJECT_PATH: &str = "/article-service";
|
||||
const OBJECT_VALIDATOR: &[u8] = b"object-v1";
|
||||
@@ -134,12 +134,12 @@ pub struct DemoReport {
|
||||
pub object_reference: Option<String>,
|
||||
/// Optional object-state validator.
|
||||
pub object_validator: Option<Vec<u8>>,
|
||||
/// Opaque interface references published by the object, in exposure order.
|
||||
pub published_interfaces: Vec<String>,
|
||||
/// Opaque reference assigned to the catalog descriptor.
|
||||
pub catalog_interface_reference: String,
|
||||
/// Opaque reference assigned to the audit descriptor.
|
||||
pub audit_interface_reference: String,
|
||||
/// Structured interface references published by the object, in exposure order.
|
||||
pub published_interfaces: Vec<InterfaceReference>,
|
||||
/// Root-scoped reference assigned to the catalog descriptor.
|
||||
pub catalog_interface_reference: InterfaceReference,
|
||||
/// Root-scoped reference assigned to the audit descriptor.
|
||||
pub audit_interface_reference: InterfaceReference,
|
||||
/// Descriptor format observed separately for the catalog reference.
|
||||
pub catalog_descriptor_format: String,
|
||||
/// Descriptor format observed separately for the audit reference.
|
||||
@@ -190,7 +190,7 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
audit: audit.clone(),
|
||||
};
|
||||
|
||||
let object = provider.fetch(OBJECT_PATH)?;
|
||||
let object = provider.observe_object(OBJECT_PATH)?;
|
||||
let catalog_response = registry
|
||||
.fetch_interface(
|
||||
object
|
||||
@@ -296,7 +296,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
validator: Some(b"stale-object".to_vec()),
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: diagnostics.reference().as_str().to_owned(),
|
||||
scope_ref: None,
|
||||
reference: diagnostics.reference().protocol_reference(),
|
||||
validator: Some(b"stale-interface".to_vec()),
|
||||
},
|
||||
operation: "missing".into(),
|
||||
@@ -319,7 +320,10 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
.code;
|
||||
|
||||
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")
|
||||
.code;
|
||||
|
||||
@@ -330,7 +334,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
validator: Some(b"stale-object".to_vec()),
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: diagnostics.reference().as_str().to_owned(),
|
||||
scope_ref: None,
|
||||
reference: diagnostics.reference().protocol_reference(),
|
||||
validator: Some(b"stale-interface".to_vec()),
|
||||
},
|
||||
operation: "missing".into(),
|
||||
@@ -345,8 +350,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
object_reference: object.r#ref,
|
||||
object_validator: object.validator,
|
||||
published_interfaces: object.interfaces,
|
||||
catalog_interface_reference: catalog.reference().as_str().to_owned(),
|
||||
audit_interface_reference: audit.reference().as_str().to_owned(),
|
||||
catalog_interface_reference: catalog.reference().protocol_reference(),
|
||||
audit_interface_reference: audit.reference().protocol_reference(),
|
||||
catalog_descriptor_format: catalog_response.descriptor.format,
|
||||
audit_descriptor_format: audit_response.descriptor.format,
|
||||
published,
|
||||
@@ -412,7 +417,8 @@ fn request(
|
||||
validator: Some(OBJECT_VALIDATOR.to_vec()),
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: interface.as_str().to_owned(),
|
||||
scope_ref: None,
|
||||
reference: interface.protocol_reference(),
|
||||
validator: interface_validator.map(<[u8]>::to_vec),
|
||||
},
|
||||
operation: operation.into(),
|
||||
|
||||
@@ -97,7 +97,7 @@ fn object_fields_keep_path_context_and_optional_ref_separate() {
|
||||
let object = Object {
|
||||
name: "article".into(),
|
||||
description: None,
|
||||
interfaces: vec!["catalog-ref".into(), "audit-ref".into()],
|
||||
interfaces: vec![root_reference("catalog-ref"), root_reference("audit-ref")],
|
||||
r#ref: None,
|
||||
validator: None,
|
||||
};
|
||||
@@ -105,7 +105,10 @@ fn object_fields_keep_path_context_and_optional_ref_separate() {
|
||||
object.validate_at_path("/library/article").unwrap();
|
||||
assert_eq!(object.name, "article");
|
||||
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]
|
||||
@@ -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]
|
||||
name = "wip-tui"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
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"
|
||||
publish = false
|
||||
|
||||
@@ -13,10 +15,10 @@ crossterm = "0.28.1"
|
||||
ratatui = "0.29.0"
|
||||
serde_json = "1"
|
||||
ureq = "3.1.2"
|
||||
wip-client = { path = "../wip-client" }
|
||||
wip-https = { path = "../wip-https" }
|
||||
wip-protocol = { path = "../wip-protocol" }
|
||||
wip-client.workspace = true
|
||||
wip-http.workspace = true
|
||||
wip-protocol.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
wip-fs-server = { path = "../wip-fs-server" }
|
||||
wip-fs-server.workspace = true
|
||||
|
||||
+60
-29
@@ -8,12 +8,17 @@ owned by the client runtime.
|
||||
|
||||
## Start
|
||||
|
||||
HTTPS is the normal mode:
|
||||
Supply one `http` or `https` endpoint:
|
||||
|
||||
```console
|
||||
cargo run -p wip-tui -- --endpoint https://wip.example.test/api/wip
|
||||
```
|
||||
|
||||
The same canonical endpoint is used unchanged to construct requests, perform
|
||||
HTTP I/O, and partition the client cache. WIP over HTTP does not define separate
|
||||
HTTPS and development-HTTP profiles; URL scheme, TLS, certificate, authentication,
|
||||
and proxy policy belong to the HTTP implementation and deployment.
|
||||
|
||||
The endpoint may instead be supplied as `WIP_ENDPOINT`. A non-secret cache
|
||||
partition identity may be supplied with `--security-context NAME` or
|
||||
`WIP_SECURITY_CONTEXT`. The default request timeout is 30 seconds and can be
|
||||
@@ -31,16 +36,6 @@ Use `--authorization-env OTHER_VARIABLE` to select another variable. The value i
|
||||
redacted from `Debug` and is never rendered. Shell and process environment security
|
||||
remain the operator's responsibility.
|
||||
|
||||
Plain HTTP is rejected by default. It is available only as an explicit loopback
|
||||
development mode:
|
||||
|
||||
```console
|
||||
cargo run -p wip-tui -- http://127.0.0.1:8080/wip --allow-http
|
||||
```
|
||||
|
||||
Non-loopback HTTP remains rejected even with `--allow-http`. For production, use a
|
||||
real HTTPS endpoint and a trusted TLS deployment.
|
||||
|
||||
### Filesystem server example
|
||||
|
||||
In one terminal:
|
||||
@@ -52,22 +47,54 @@ cargo run -p wip-fs-server -- \
|
||||
--root /tmp/wip-example \
|
||||
--listen 127.0.0.1:8080 \
|
||||
--base-path /wip \
|
||||
--development-http \
|
||||
--read-write
|
||||
```
|
||||
|
||||
In another:
|
||||
|
||||
```console
|
||||
cargo run -p wip-tui -- http://127.0.0.1:8080/wip --allow-http
|
||||
cargo run -p wip-tui -- http://127.0.0.1:8080/wip
|
||||
```
|
||||
|
||||
Expand `/`, select `notes.txt`, focus the Interface/Operation pane, load the read
|
||||
interface, and choose `read`. The editor labels `offset` and `length` as optional;
|
||||
leave both blank to read the bounded file. `Bytes` results are shown as a bounded
|
||||
hex preview. For `replace`, enter `contents` as RFC 4648 base64—for example,
|
||||
`dXBkYXRlZA==` for `updated`—then review the target Entry, interface reference,
|
||||
operation, and value on the confirmation screen before pressing Enter again.
|
||||
Expand `/`, select `notes.txt`, focus the Interface/Operation pane, and choose
|
||||
`read`; descriptors advertised by visible Objects are normally prefetched already.
|
||||
The editor labels `offset` and `length` as optional; leave both blank to read the
|
||||
bounded file. `Bytes` results are shown as a bounded hex preview. For `replace`,
|
||||
enter `contents` as RFC 4648 base64—for example, `dXBkYXRlZA==` for
|
||||
`updated`—then review the target Entry, interface reference, operation, and value
|
||||
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
|
||||
|
||||
@@ -80,7 +107,6 @@ All required actions are keyboard-accessible.
|
||||
| `←`/`h` | Collapse a node or move to its parent |
|
||||
| `Tab` | Cycle Tree → Selected Entry → Operations → History |
|
||||
| `r` | Reobserve the selected Entry and its direct subtree |
|
||||
| `+` | Double the selected node limit and retry an incomplete/truncated tree |
|
||||
| `c` | Cancel the newest retrieval; a dispatched operation is never presented as canceled |
|
||||
| `R` | Reconnect after connection failure |
|
||||
| `?` | Open help |
|
||||
@@ -93,17 +119,22 @@ a second explicit Enter (or `e`) to execute once. There is no operation auto-ret
|
||||
|
||||
## Screen model
|
||||
|
||||
- **Worldspace tree** starts at `/` and materializes direct children only when a
|
||||
node is expanded. Text labels distinguish unloaded, loading, empty, stale,
|
||||
failed, and truncated state; color is supplemental. A truncated node is never
|
||||
presented as a complete tree.
|
||||
- **Worldspace tree** starts at `/`, lazily materializes expanded nodes, and
|
||||
prefetches children for each visible Object so emptiness and the next expansion
|
||||
frontier are known without selecting that Object first. Text labels distinguish
|
||||
unloaded, loading, empty, stale, and failed state; color is supplemental. Every
|
||||
successful expansion is complete for its requested depth.
|
||||
- **Selected Entry** shows only public WIP path, object name, description, ordered
|
||||
interfaces, and freshness. Validators, credentials, Host internals, and backend
|
||||
paths are deliberately hidden. If current metadata disappears, old text is not
|
||||
presented as current.
|
||||
- **Interfaces / Operations** identifies every operation by `(interface reference,
|
||||
operation name)`. It shows descriptor documentation, ordered parameters,
|
||||
required/optional status, and result types.
|
||||
- **Interfaces / Operations** identifies every operation by `(interface scope,
|
||||
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.
|
||||
The pane shows descriptor documentation, ordered parameters, required/optional
|
||||
status, and result types.
|
||||
- **Argument editor** supports Unit, Boolean, Integer, Number, String, base64 Bytes,
|
||||
Record, List, Entry, Enum, Union, Json, and descriptor-local named types. Record,
|
||||
List, Union, and Json values use JSON syntax. Union input is
|
||||
@@ -121,8 +152,8 @@ layout.
|
||||
## Validator and failure behavior
|
||||
|
||||
Object and interface validators are observed and attached automatically by
|
||||
`wip-client`; users neither copy nor edit them. A successful operation conservatively
|
||||
marks related object observations stale because metadata may have changed.
|
||||
`wip-client`; users neither copy nor edit them. A successful operation may update the
|
||||
cached Object validator but does not by itself make the Object observation stale.
|
||||
Validator mismatch, interface mismatch, `OperationOutcomeUnknown`, undecodable
|
||||
post-dispatch responses, and post-dispatch timeout/disconnect are displayed using
|
||||
the runtime's authoritative transition.
|
||||
|
||||
+152
-66
@@ -3,7 +3,7 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use wip_client::{InterfaceObservation, ObjectObservation, ObservationState, TreeObservation};
|
||||
use wip_protocol::{InterfaceDescriptor, Object, Value};
|
||||
use wip_protocol::{InterfaceDescriptor, InterfaceReference, Object, Value};
|
||||
|
||||
use crate::editor::ArgumentEditor;
|
||||
|
||||
@@ -31,8 +31,6 @@ pub enum InputKey {
|
||||
Backspace,
|
||||
/// Refresh the selected Entry.
|
||||
Refresh,
|
||||
/// Retry a truncated tree with a larger limit.
|
||||
IncreaseLimit,
|
||||
/// Cancel the newest cancel-safe retrieval.
|
||||
CancelRead,
|
||||
/// Show key help.
|
||||
@@ -123,14 +121,10 @@ pub struct EntryView {
|
||||
pub tree_state: ViewState,
|
||||
/// Whether the user expanded this row.
|
||||
pub expanded: bool,
|
||||
/// Whether the observed child list is incomplete.
|
||||
pub truncated: bool,
|
||||
/// Current bounded expansion limit.
|
||||
pub limit: u32,
|
||||
}
|
||||
|
||||
impl EntryView {
|
||||
fn placeholder(path: impl Into<String>, default_limit: u32) -> Self {
|
||||
fn placeholder(path: impl Into<String>) -> Self {
|
||||
Self {
|
||||
path: path.into(),
|
||||
object: None,
|
||||
@@ -138,8 +132,6 @@ impl EntryView {
|
||||
children: Vec::new(),
|
||||
tree_state: ViewState::Unloaded,
|
||||
expanded: false,
|
||||
truncated: false,
|
||||
limit: default_limit,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,8 +139,8 @@ impl EntryView {
|
||||
/// Cached interface descriptor state used by presentation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InterfaceView {
|
||||
/// Opaque interface reference.
|
||||
pub reference: String,
|
||||
/// Structured interface reference.
|
||||
pub reference: InterfaceReference,
|
||||
/// Descriptor when accepted by the client runtime.
|
||||
pub descriptor: Option<InterfaceDescriptor>,
|
||||
/// Descriptor observation state.
|
||||
@@ -195,13 +187,13 @@ pub enum InFlightKind {
|
||||
/// Tree expansion or refresh.
|
||||
Tree(String),
|
||||
/// Descriptor fetch.
|
||||
Interface(String),
|
||||
Interface(InterfaceReference),
|
||||
/// Explicit operation call.
|
||||
Operation {
|
||||
/// Target path.
|
||||
path: String,
|
||||
/// Interface reference.
|
||||
interface: String,
|
||||
interface: InterfaceReference,
|
||||
/// Operation name.
|
||||
operation: String,
|
||||
/// Whether the transport dispatch boundary was crossed.
|
||||
@@ -242,24 +234,20 @@ pub enum Action {
|
||||
Expand {
|
||||
/// Tree root path.
|
||||
path: String,
|
||||
/// Maximum returned nodes.
|
||||
limit: u32,
|
||||
},
|
||||
/// Reobserve an Entry and its direct children.
|
||||
Refresh {
|
||||
/// Selected path.
|
||||
path: String,
|
||||
/// Current tree limit.
|
||||
limit: u32,
|
||||
},
|
||||
/// Retrieve one interface descriptor.
|
||||
FetchInterface(String),
|
||||
FetchInterface(InterfaceReference),
|
||||
/// Execute only after explicit confirmation.
|
||||
Execute {
|
||||
/// Entry target.
|
||||
path: String,
|
||||
/// Interface reference.
|
||||
interface: String,
|
||||
interface: InterfaceReference,
|
||||
/// Operation name.
|
||||
operation: String,
|
||||
/// Descriptor-validated arguments.
|
||||
@@ -267,7 +255,7 @@ pub enum Action {
|
||||
},
|
||||
/// Cancel the newest safe retrieval.
|
||||
CancelRead,
|
||||
/// Reconnect and fetch the root.
|
||||
/// Reconnect and observe the root.
|
||||
Reconnect,
|
||||
/// Navigate to a descriptor-declared Entry result.
|
||||
NavigateEntry(String),
|
||||
@@ -288,8 +276,8 @@ pub struct App {
|
||||
pub selected_path: String,
|
||||
/// Focused pane.
|
||||
pub focus: Focus,
|
||||
/// Interface descriptor views by opaque reference.
|
||||
pub interfaces: BTreeMap<String, InterfaceView>,
|
||||
/// Interface descriptor views by structured reference.
|
||||
pub interfaces: BTreeMap<InterfaceReference, InterfaceView>,
|
||||
/// Selected row in the flattened interface/operation view.
|
||||
pub operation_cursor: usize,
|
||||
/// Selected recent-history row.
|
||||
@@ -302,8 +290,6 @@ pub struct App {
|
||||
pub history: Vec<HistoryItem>,
|
||||
/// Maximum retained presentation history.
|
||||
pub history_limit: usize,
|
||||
/// Default node limit for lazy expansion.
|
||||
pub default_tree_limit: u32,
|
||||
/// One-line status for local action feedback.
|
||||
pub status: String,
|
||||
}
|
||||
@@ -312,9 +298,10 @@ impl App {
|
||||
/// Creates a disconnected model with `/` selected.
|
||||
#[must_use]
|
||||
pub fn new(endpoint: impl Into<String>) -> Self {
|
||||
let default_tree_limit = 64;
|
||||
let mut entries = BTreeMap::new();
|
||||
entries.insert("/".into(), EntryView::placeholder("/", default_tree_limit));
|
||||
let mut root = EntryView::placeholder("/");
|
||||
root.expanded = true;
|
||||
entries.insert("/".into(), root);
|
||||
Self {
|
||||
endpoint: endpoint.into(),
|
||||
connection: ConnectionState::Disconnected,
|
||||
@@ -328,7 +315,6 @@ impl App {
|
||||
in_flight: BTreeMap::new(),
|
||||
history: Vec::new(),
|
||||
history_limit: 64,
|
||||
default_tree_limit,
|
||||
status: "press ? for help".into(),
|
||||
}
|
||||
}
|
||||
@@ -441,14 +427,11 @@ impl App {
|
||||
let node = self
|
||||
.entries
|
||||
.entry(self.selected_path.clone())
|
||||
.or_insert_with(|| {
|
||||
EntryView::placeholder(&self.selected_path, self.default_tree_limit)
|
||||
});
|
||||
.or_insert_with(|| EntryView::placeholder(&self.selected_path));
|
||||
node.expanded = true;
|
||||
if !matches!(node.tree_state, ViewState::Fresh) || node.truncated {
|
||||
if !matches!(node.tree_state, ViewState::Fresh) {
|
||||
return vec![Action::Expand {
|
||||
path: node.path.clone(),
|
||||
limit: node.limit,
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -464,18 +447,6 @@ impl App {
|
||||
}
|
||||
}
|
||||
InputKey::Refresh => return vec![self.refresh_action()],
|
||||
InputKey::IncreaseLimit => {
|
||||
let node = self
|
||||
.entries
|
||||
.get_mut(&self.selected_path)
|
||||
.expect("selected exists");
|
||||
node.limit = node.limit.saturating_mul(2).max(1);
|
||||
node.expanded = true;
|
||||
return vec![Action::Expand {
|
||||
path: node.path.clone(),
|
||||
limit: node.limit,
|
||||
}];
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.operation_cursor = 0;
|
||||
@@ -537,13 +508,8 @@ impl App {
|
||||
}
|
||||
|
||||
fn refresh_action(&self) -> Action {
|
||||
let limit = self
|
||||
.entries
|
||||
.get(&self.selected_path)
|
||||
.map_or(self.default_tree_limit, |entry| entry.limit);
|
||||
Action::Refresh {
|
||||
path: self.selected_path.clone(),
|
||||
limit,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -591,9 +557,7 @@ impl App {
|
||||
let children = observation.children.clone();
|
||||
let entry = self.ensure_entry(&observation.path);
|
||||
entry.children = children.clone();
|
||||
entry.truncated = observation.truncated;
|
||||
entry.tree_state = ViewState::from(&observation.state);
|
||||
entry.expanded = true;
|
||||
for path in children {
|
||||
self.ensure_entry(&path);
|
||||
}
|
||||
@@ -648,6 +612,30 @@ impl App {
|
||||
visible
|
||||
}
|
||||
|
||||
/// Returns interface references advertised by fresh visible Objects in display
|
||||
/// and per-Object declaration order, with duplicates removed.
|
||||
#[must_use]
|
||||
pub fn visible_interface_references(&self) -> Vec<InterfaceReference> {
|
||||
let mut references = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
for path in self.visible_paths() {
|
||||
let Some(object) = self
|
||||
.entries
|
||||
.get(&path)
|
||||
.filter(|entry| entry.state == ViewState::Fresh)
|
||||
.and_then(|entry| entry.object.as_ref())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for reference in &object.interfaces {
|
||||
if seen.insert(reference.clone()) {
|
||||
references.push(reference.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
references
|
||||
}
|
||||
|
||||
fn collect_visible(&self, path: &str, seen: &mut BTreeSet<String>, visible: &mut Vec<String>) {
|
||||
if !seen.insert(path.to_owned()) {
|
||||
return;
|
||||
@@ -725,10 +713,10 @@ impl App {
|
||||
}
|
||||
self.entries
|
||||
.entry(path.to_owned())
|
||||
.or_insert_with(|| EntryView::placeholder(path, self.default_tree_limit))
|
||||
.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)
|
||||
&& self.interfaces.len() >= MAX_PRESENTATION_INTERFACES
|
||||
{
|
||||
@@ -769,7 +757,7 @@ impl App {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OperationRow {
|
||||
/// Interface reference, which participates in operation identity.
|
||||
pub interface: String,
|
||||
pub interface: InterfaceReference,
|
||||
/// Operation name, or `None` for the interface heading.
|
||||
pub operation: Option<String>,
|
||||
}
|
||||
@@ -785,6 +773,12 @@ pub fn parent_path(path: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||
wip_protocol::InterfaceReference {
|
||||
scope: "/".into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
use super::*;
|
||||
use wip_protocol::{INTERFACE_FORMAT_V1, OperationDeclaration, ReturnDeclaration, TypeExpr};
|
||||
|
||||
@@ -792,33 +786,123 @@ mod tests {
|
||||
Object {
|
||||
name: name.into(),
|
||||
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,
|
||||
validator: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_navigation_expansion_parent_and_limit_are_pure() {
|
||||
fn tree_navigation_and_lazy_expansion_are_pure() {
|
||||
let mut app = App::new("https://example.test/wip");
|
||||
app.apply_tree(TreeObservation {
|
||||
path: "/".into(),
|
||||
children: vec!["/a".into(), "/b".into()],
|
||||
truncated: true,
|
||||
revision: request_id(),
|
||||
state: ObservationState::Fresh,
|
||||
});
|
||||
assert_eq!(app.visible_paths(), ["/", "/a", "/b"]);
|
||||
app.reduce(InputKey::Down);
|
||||
assert_eq!(app.selected_path, "/a");
|
||||
app.reduce(InputKey::Left);
|
||||
assert_eq!(app.selected_path, "/");
|
||||
assert!(matches!(
|
||||
app.reduce(InputKey::IncreaseLimit).as_slice(),
|
||||
[Action::Expand { path, limit: 128 }] if path == "/"
|
||||
app.reduce(InputKey::Right).as_slice(),
|
||||
[Action::Expand { path }] if path == "/a"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_interface_references_are_ordered_deduplicated_and_fresh() {
|
||||
let mut app = App::new("https://example.test/wip");
|
||||
app.apply_tree(TreeObservation {
|
||||
path: "/".into(),
|
||||
children: vec!["/a".into(), "/stale".into()],
|
||||
revision: request_id(),
|
||||
state: ObservationState::Fresh,
|
||||
});
|
||||
app.apply_object(ObjectObservation {
|
||||
path: "/".into(),
|
||||
object: Some(object("", &["root", "shared"])),
|
||||
validator: None,
|
||||
state: ObservationState::Fresh,
|
||||
});
|
||||
app.apply_object(ObjectObservation {
|
||||
path: "/a".into(),
|
||||
object: Some(object("a", &["shared", "child"])),
|
||||
validator: None,
|
||||
state: ObservationState::Fresh,
|
||||
});
|
||||
app.apply_object(ObjectObservation {
|
||||
path: "/stale".into(),
|
||||
object: Some(object("stale", &["ignored"])),
|
||||
validator: None,
|
||||
state: ObservationState::Stale,
|
||||
});
|
||||
app.apply_tree(TreeObservation {
|
||||
path: "/a".into(),
|
||||
children: vec!["/a/hidden".into()],
|
||||
revision: request_id(),
|
||||
state: ObservationState::Fresh,
|
||||
});
|
||||
app.apply_object(ObjectObservation {
|
||||
path: "/a/hidden".into(),
|
||||
object: Some(object("hidden", &["hidden"])),
|
||||
validator: None,
|
||||
state: ObservationState::Fresh,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
app.visible_interface_references(),
|
||||
[
|
||||
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]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_modal_and_explicit_confirmation_gate_execution() {
|
||||
let mut app = App::new("https://example.test/wip");
|
||||
@@ -843,7 +927,8 @@ mod tests {
|
||||
}],
|
||||
};
|
||||
app.apply_interface(InterfaceObservation {
|
||||
reference: "iface".into(),
|
||||
scope_ref: None,
|
||||
reference: root_reference("iface"),
|
||||
descriptor: Some(descriptor),
|
||||
validator: None,
|
||||
state: ObservationState::Fresh,
|
||||
@@ -872,7 +957,8 @@ mod tests {
|
||||
|
||||
for index in 0..(MAX_PRESENTATION_INTERFACES + 50) {
|
||||
app.apply_interface(InterfaceObservation {
|
||||
reference: format!("interface-{index}"),
|
||||
scope_ref: None,
|
||||
reference: root_reference(&format!("interface-{index}")),
|
||||
descriptor: None,
|
||||
validator: None,
|
||||
state: ObservationState::Fresh,
|
||||
@@ -908,7 +994,7 @@ mod tests {
|
||||
// A request identity has no public constructor; derive one through the runtime.
|
||||
let mut client = wip_client::Client::new(
|
||||
wip_client::ClientLimits::new(1, 4, 4, 4, 4).unwrap(),
|
||||
wip_https::Limits::new(1024, 1024, 8, 8).unwrap(),
|
||||
wip_http::Limits::new(1024, 1024, 8).unwrap(),
|
||||
);
|
||||
let session = client
|
||||
.open_session(
|
||||
@@ -916,6 +1002,6 @@ mod tests {
|
||||
wip_client::SecurityContext::new("test"),
|
||||
)
|
||||
.unwrap();
|
||||
client.prepare_fetch(&session, "/").unwrap().id
|
||||
client.refresh_object(&session, "/").unwrap().id
|
||||
}
|
||||
}
|
||||
|
||||
+34
-110
@@ -1,10 +1,10 @@
|
||||
//! Secure-by-default command-line and environment configuration.
|
||||
//! Command-line and environment configuration for the HTTP client.
|
||||
|
||||
use std::error::Error;
|
||||
use std::fmt::{self, Debug, Display, Formatter};
|
||||
use std::time::Duration;
|
||||
|
||||
use wip_https::url::Url;
|
||||
use wip_http::Endpoint;
|
||||
|
||||
/// Credential wrapper whose debug output is always redacted.
|
||||
#[derive(Clone)]
|
||||
@@ -25,45 +25,33 @@ impl Debug for Secret {
|
||||
/// Validated runtime configuration.
|
||||
#[derive(Clone)]
|
||||
pub struct Config {
|
||||
/// Canonical logical HTTPS endpoint used by `wip-client`.
|
||||
logical_endpoint: String,
|
||||
/// Actual request origin; differs only for explicit loopback development HTTP.
|
||||
transport_endpoint: String,
|
||||
/// Canonical endpoint used for request construction, transport, and cache partitioning.
|
||||
endpoint: String,
|
||||
/// Non-secret cache partition identity.
|
||||
security_context: String,
|
||||
/// Bounded network timeout.
|
||||
timeout: Duration,
|
||||
/// Optional complete Authorization header value, never rendered or logged.
|
||||
authorization: Option<Secret>,
|
||||
/// Whether loopback request URIs are deliberately downgraded to HTTP.
|
||||
development_http: bool,
|
||||
}
|
||||
|
||||
impl Debug for Config {
|
||||
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("Config")
|
||||
.field("logical_endpoint", &self.logical_endpoint)
|
||||
.field("transport_endpoint", &self.transport_endpoint)
|
||||
.field("endpoint", &self.endpoint)
|
||||
.field("security_context", &self.security_context)
|
||||
.field("timeout", &self.timeout)
|
||||
.field("authorization", &self.authorization)
|
||||
.field("development_http", &self.development_http)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Returns the canonical HTTPS endpoint used for protocol request construction.
|
||||
/// Returns the canonical endpoint used by the client and HTTP transport.
|
||||
#[must_use]
|
||||
pub fn logical_endpoint(&self) -> &str {
|
||||
&self.logical_endpoint
|
||||
}
|
||||
|
||||
/// Returns the validated effective transport endpoint shown in the UI.
|
||||
#[must_use]
|
||||
pub fn transport_endpoint(&self) -> &str {
|
||||
&self.transport_endpoint
|
||||
pub fn endpoint(&self) -> &str {
|
||||
&self.endpoint
|
||||
}
|
||||
|
||||
/// Returns the non-secret session cache partition identity.
|
||||
@@ -78,12 +66,6 @@ impl Config {
|
||||
self.timeout
|
||||
}
|
||||
|
||||
/// Whether explicit loopback development HTTP is enabled.
|
||||
#[must_use]
|
||||
pub const fn development_http(&self) -> bool {
|
||||
self.development_http
|
||||
}
|
||||
|
||||
pub(crate) fn authorization(&self) -> Option<&Secret> {
|
||||
self.authorization.as_ref()
|
||||
}
|
||||
@@ -115,7 +97,6 @@ impl Config {
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
let mut endpoint = None;
|
||||
let mut allow_http = false;
|
||||
let mut timeout = Duration::from_secs(30);
|
||||
let mut security_context = security_context_env.unwrap_or_else(|| "anonymous".into());
|
||||
let mut authorization_env = "WIP_AUTHORIZATION".to_owned();
|
||||
@@ -123,7 +104,6 @@ impl Config {
|
||||
while let Some(argument) = iter.next() {
|
||||
match argument.as_str() {
|
||||
"--endpoint" => endpoint = Some(next_value(&mut iter, "--endpoint")?),
|
||||
"--allow-http" => allow_http = true,
|
||||
"--security-context" => {
|
||||
security_context = next_value(&mut iter, "--security-context")?;
|
||||
}
|
||||
@@ -149,52 +129,22 @@ impl Config {
|
||||
_ => return Err(ConfigError::Invalid("multiple endpoints supplied".into())),
|
||||
}
|
||||
}
|
||||
let endpoint = endpoint
|
||||
let supplied = endpoint
|
||||
.or(endpoint_env)
|
||||
.ok_or_else(|| ConfigError::Invalid("endpoint is required".into()))?;
|
||||
let mut actual = Url::parse(&endpoint)
|
||||
.map_err(|_| ConfigError::Invalid("endpoint is not a valid absolute URL".into()))?;
|
||||
if actual.query().is_some()
|
||||
|| actual.fragment().is_some()
|
||||
|| actual.host_str().is_none()
|
||||
|| !actual.username().is_empty()
|
||||
|| actual.password().is_some()
|
||||
{
|
||||
let endpoint =
|
||||
Endpoint::parse(&supplied).map_err(|error| ConfigError::Invalid(error.to_string()))?;
|
||||
if !endpoint.as_url().username().is_empty() || endpoint.as_url().password().is_some() {
|
||||
return Err(ConfigError::Invalid(
|
||||
"endpoint must be an absolute URL without userinfo, query, or fragment".into(),
|
||||
"endpoint userinfo is not accepted; supply credentials through the configured environment variable"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
if !actual.path().ends_with('/') {
|
||||
let path = format!("{}/", actual.path());
|
||||
actual.set_path(&path);
|
||||
if !matches!(endpoint.as_url().scheme(), "http" | "https") {
|
||||
return Err(ConfigError::Invalid(
|
||||
"this HTTP client supports http and https endpoint schemes".into(),
|
||||
));
|
||||
}
|
||||
let development_http = actual.scheme() == "http";
|
||||
match actual.scheme() {
|
||||
"https" => {}
|
||||
"http" if allow_http && loopback_host(&actual) => {}
|
||||
"http" if !allow_http => {
|
||||
return Err(ConfigError::Invalid(
|
||||
"plain HTTP requires the explicit --allow-http development option".into(),
|
||||
));
|
||||
}
|
||||
"http" => {
|
||||
return Err(ConfigError::Invalid(
|
||||
"development HTTP is restricted to loopback endpoints".into(),
|
||||
));
|
||||
}
|
||||
_ => return Err(ConfigError::Invalid("endpoint must use HTTPS".into())),
|
||||
}
|
||||
let transport_endpoint = actual.to_string();
|
||||
let mut logical = actual;
|
||||
if development_http {
|
||||
logical
|
||||
.set_scheme("https")
|
||||
.expect("http and https are interchangeable hierarchical schemes");
|
||||
}
|
||||
// Let the binding enforce all remaining canonical endpoint rules.
|
||||
let logical_endpoint = wip_https::Endpoint::parse(logical.as_str())
|
||||
.map_err(|error| ConfigError::Invalid(error.to_string()))?
|
||||
.to_string();
|
||||
if security_context.is_empty() {
|
||||
return Err(ConfigError::Invalid(
|
||||
"security context must not be empty".into(),
|
||||
@@ -204,20 +154,18 @@ impl Config {
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(Secret);
|
||||
Ok(Self {
|
||||
logical_endpoint,
|
||||
transport_endpoint,
|
||||
endpoint: endpoint.to_string(),
|
||||
security_context,
|
||||
timeout,
|
||||
authorization,
|
||||
development_http,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns concise command usage without mentioning credential values.
|
||||
#[must_use]
|
||||
pub const fn usage() -> &'static str {
|
||||
"usage: wip-tui [--endpoint] URL [--allow-http] [--security-context NAME] [--authorization-env VAR] [--timeout SECONDS]\n\
|
||||
URL may also be supplied as WIP_ENDPOINT; HTTPS is required unless loopback HTTP is explicitly enabled."
|
||||
"usage: wip-tui [--endpoint] URL [--security-context NAME] [--authorization-env VAR] [--timeout SECONDS]\n\
|
||||
URL may also be supplied as WIP_ENDPOINT; the HTTP adapter supports http and https."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,15 +177,6 @@ fn next_value(
|
||||
.ok_or_else(|| ConfigError::Invalid(format!("{option} requires a value")))
|
||||
}
|
||||
|
||||
fn loopback_host(url: &Url) -> bool {
|
||||
match url.host() {
|
||||
Some(wip_https::url::Host::Ipv4(address)) => address.is_loopback(),
|
||||
Some(wip_https::url::Host::Ipv6(address)) => address.is_loopback(),
|
||||
Some(wip_https::url::Host::Domain(name)) => name.eq_ignore_ascii_case("localhost"),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Startup configuration failure.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ConfigError {
|
||||
@@ -263,7 +202,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn https_is_default_and_secrets_are_redacted() {
|
||||
fn endpoint_is_canonical_and_secrets_are_redacted() {
|
||||
let config = Config::parse(
|
||||
["https://example.test/wip", "--authorization-env", "TOKEN"],
|
||||
None,
|
||||
@@ -271,7 +210,7 @@ mod tests {
|
||||
|name| (name == "TOKEN").then(|| "Bearer secret-value".into()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(config.logical_endpoint(), "https://example.test/wip/");
|
||||
assert_eq!(config.endpoint(), "https://example.test/wip/");
|
||||
let debug = format!("{config:?}");
|
||||
assert!(debug.contains("[REDACTED]"));
|
||||
assert!(!debug.contains("secret-value"));
|
||||
@@ -279,37 +218,22 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn endpoint_userinfo_is_rejected_before_it_can_reach_debug_or_render_state() {
|
||||
for arguments in [
|
||||
vec!["https://user:password@example.test/wip"],
|
||||
vec!["http://user:password@localhost:3000/wip", "--allow-http"],
|
||||
for endpoint in [
|
||||
"https://user:password@example.test/wip",
|
||||
"http://user:password@localhost:3000/wip",
|
||||
] {
|
||||
let error = Config::parse(arguments, None, None, |_| None).unwrap_err();
|
||||
assert!(error.to_string().contains("without userinfo"));
|
||||
let error = Config::parse([endpoint], None, None, |_| None).unwrap_err();
|
||||
assert!(error.to_string().contains("userinfo"));
|
||||
assert!(!error.to_string().contains("password"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_requires_explicit_loopback_development_mode() {
|
||||
assert!(Config::parse(["http://localhost:3000/wip"], None, None, |_| None).is_err());
|
||||
let config = Config::parse(
|
||||
["http://127.0.0.1:3000/wip", "--allow-http"],
|
||||
None,
|
||||
None,
|
||||
|_| None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(config.logical_endpoint(), "https://127.0.0.1:3000/wip/");
|
||||
assert_eq!(config.transport_endpoint(), "http://127.0.0.1:3000/wip/");
|
||||
assert!(config.development_http());
|
||||
assert!(
|
||||
Config::parse(
|
||||
["http://example.test/wip", "--allow-http"],
|
||||
None,
|
||||
None,
|
||||
|_| None,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
fn http_and_https_are_preserved_as_the_single_endpoint() {
|
||||
for expected in ["http://example.test:3000/wip/", "https://example.test/wip/"] {
|
||||
let supplied = expected.trim_end_matches('/');
|
||||
let config = Config::parse([supplied], None, None, |_| None).unwrap();
|
||||
assert_eq!(config.endpoint(), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ use std::collections::BTreeMap;
|
||||
|
||||
use base64::Engine as _;
|
||||
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;
|
||||
|
||||
@@ -30,8 +32,8 @@ pub struct InvalidArguments;
|
||||
/// Pure, terminal-independent editor built from an operation descriptor.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ArgumentEditor {
|
||||
/// Opaque interface reference selected for the call.
|
||||
pub interface: String,
|
||||
/// Structured interface reference selected for the call.
|
||||
pub interface: InterfaceReference,
|
||||
/// Selected operation declaration.
|
||||
pub operation: OperationDeclaration,
|
||||
/// Complete descriptor used to resolve named types.
|
||||
@@ -46,7 +48,7 @@ impl ArgumentEditor {
|
||||
/// Builds an editor whose fields preserve descriptor order.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
interface: impl Into<String>,
|
||||
interface: InterfaceReference,
|
||||
descriptor: InterfaceDescriptor,
|
||||
operation: OperationDeclaration,
|
||||
) -> Self {
|
||||
@@ -66,7 +68,7 @@ impl ArgumentEditor {
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
interface: interface.into(),
|
||||
interface,
|
||||
operation,
|
||||
descriptor,
|
||||
fields,
|
||||
@@ -466,6 +468,12 @@ fn json_to_untyped(json: &serde_json::Value, field: &str) -> Result<Value, Strin
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||
wip_protocol::InterfaceReference {
|
||||
scope: "/".into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
use super::*;
|
||||
use wip_protocol::{
|
||||
EnumCase, FieldDeclaration, INTERFACE_FORMAT_V1, ParameterDeclaration, ReturnDeclaration,
|
||||
@@ -560,7 +568,7 @@ mod tests {
|
||||
}],
|
||||
);
|
||||
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 = [
|
||||
"null",
|
||||
"true",
|
||||
@@ -595,7 +603,7 @@ mod tests {
|
||||
vec![],
|
||||
);
|
||||
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();
|
||||
assert_eq!(editor.validate(), Err(InvalidArguments));
|
||||
assert!(
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
pub mod app;
|
||||
pub mod config;
|
||||
pub mod editor;
|
||||
pub mod reference;
|
||||
pub mod render;
|
||||
pub mod runtime;
|
||||
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;
|
||||
|
||||
fn interface_label(reference: &wip_protocol::InterfaceReference) -> String {
|
||||
format!(
|
||||
"scope={}, name={}",
|
||||
sanitize(&reference.scope),
|
||||
sanitize(&reference.name)
|
||||
)
|
||||
}
|
||||
|
||||
/// Draws the complete application without mutating it.
|
||||
pub fn draw(frame: &mut Frame<'_>, app: &App) {
|
||||
let area = frame.area();
|
||||
@@ -113,13 +121,8 @@ fn render_tree(frame: &mut Frame<'_>, area: Rect, app: &App) {
|
||||
ViewState::Fresh if node.children.is_empty() => " [empty]",
|
||||
_ => "",
|
||||
};
|
||||
let incomplete = if node.truncated {
|
||||
" [truncated: + to increase limit]"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let object = state_suffix(&node.state);
|
||||
format!("{object}{tree}{incomplete}")
|
||||
format!("{object}{tree}")
|
||||
},
|
||||
);
|
||||
suffix = sanitize(&suffix);
|
||||
@@ -167,7 +170,7 @@ fn render_selected(frame: &mut Frame<'_>, area: Rect, app: &App) {
|
||||
object
|
||||
.interfaces
|
||||
.iter()
|
||||
.map(|value| sanitize(value))
|
||||
.map(interface_label)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
@@ -178,14 +181,6 @@ fn render_selected(frame: &mut Frame<'_>, area: Rect, app: &App) {
|
||||
"No current object metadata; refresh required (old metadata hidden).",
|
||||
));
|
||||
}
|
||||
if entry.truncated {
|
||||
lines.push(Line::styled(
|
||||
"TRUNCATED: this is not a complete subtree.",
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines)
|
||||
@@ -237,7 +232,7 @@ fn operation_line(app: &App, row: &OperationRow) -> (Line<'static>, Style) {
|
||||
.find(|operation| operation.name == *name)
|
||||
});
|
||||
let signature = declaration.map_or_else(
|
||||
|| format!(" ({}, {})", row.interface, name),
|
||||
|| format!(" ({}, {})", interface_label(&row.interface), name),
|
||||
|operation| {
|
||||
let parameters = operation
|
||||
.parameters
|
||||
@@ -259,7 +254,7 @@ fn operation_line(app: &App, row: &OperationRow) -> (Line<'static>, Style) {
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
" ({}, {}) ({parameters}) -> {}{}",
|
||||
row.interface,
|
||||
interface_label(&row.interface),
|
||||
name,
|
||||
type_name(&operation.returns.r#type),
|
||||
documentation
|
||||
@@ -279,7 +274,7 @@ fn operation_line(app: &App, row: &OperationRow) -> (Line<'static>, Style) {
|
||||
(
|
||||
Line::from(format!(
|
||||
"Interface {} [{}]{}",
|
||||
sanitize(&row.interface),
|
||||
interface_label(&row.interface),
|
||||
state,
|
||||
documentation
|
||||
)),
|
||||
@@ -355,7 +350,7 @@ fn render_modal(frame: &mut Frame<'_>, app: &App) {
|
||||
frame.render_widget(Clear, area);
|
||||
frame.render_widget(
|
||||
Paragraph::new(
|
||||
"Keyboard help\n\n↑/↓ or j/k move selection / editor field\n←/→ or h/l collapse/expand or parent\nTab next pane\nEnter load interface, edit operation, confirm execute\nr refresh selected Entry and subtree\n+ increase truncated tree limit and retry\nc cancel newest retrieval (never cancels a sent operation)\nR reconnect after failure\nEsc close modal\nq quit\n\nOperations are never sent until the confirmation screen is accepted.",
|
||||
"Keyboard help\n\n↑/↓ or j/k move selection / editor field\n←/→ or h/l collapse/expand or parent\nTab next pane\nEnter load interface, edit operation, confirm execute\nr refresh selected Entry and subtree\nc cancel newest retrieval (never cancels a sent operation)\nR reconnect after failure\nEsc close modal\nq quit\n\nOperations are never sent until the confirmation screen is accepted.",
|
||||
)
|
||||
.block(Block::default().borders(Borders::ALL).title(" Help "))
|
||||
.wrap(Wrap { trim: false }),
|
||||
@@ -368,7 +363,7 @@ fn render_modal(frame: &mut Frame<'_>, app: &App) {
|
||||
let mut lines = vec![Line::from(format!(
|
||||
"Target: {} Operation: ({}, {})",
|
||||
sanitize(&app.selected_path),
|
||||
sanitize(&editor.interface),
|
||||
interface_label(&editor.interface),
|
||||
sanitize(&editor.operation.name)
|
||||
))];
|
||||
if editor.fields.is_empty() {
|
||||
@@ -418,7 +413,7 @@ fn render_modal(frame: &mut Frame<'_>, app: &App) {
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
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("Input values:"),
|
||||
];
|
||||
@@ -592,6 +587,12 @@ fn bounded_value_at(value: &wip_protocol::Value, depth: usize) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||
wip_protocol::InterfaceReference {
|
||||
scope: "/".into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
@@ -621,11 +622,40 @@ mod tests {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observed_empty_visible_entry_is_labeled_without_expansion() {
|
||||
let mut app = App::new("https://example.test/wip/");
|
||||
{
|
||||
let root = app.entries.get_mut("/").unwrap();
|
||||
root.children = vec!["/empty".into()];
|
||||
root.tree_state = ViewState::Fresh;
|
||||
}
|
||||
app.entries.insert(
|
||||
"/empty".into(),
|
||||
EntryView {
|
||||
path: "/empty".into(),
|
||||
object: Some(Object {
|
||||
name: "empty".into(),
|
||||
description: None,
|
||||
interfaces: vec![],
|
||||
r#ref: None,
|
||||
validator: None,
|
||||
}),
|
||||
state: ViewState::Fresh,
|
||||
children: vec![],
|
||||
tree_state: ViewState::Fresh,
|
||||
expanded: false,
|
||||
},
|
||||
);
|
||||
|
||||
let snapshot = rendered(&app, 80, 20);
|
||||
assert!(snapshot.contains("empty [empty]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_contains_non_color_state_markers_and_hides_validators() {
|
||||
let mut app = App::new("https://example.test/wip/");
|
||||
app.connection = ConnectionState::Connected;
|
||||
app.entries.get_mut("/").unwrap().truncated = true;
|
||||
app.entries.get_mut("/").unwrap().state = ViewState::Stale;
|
||||
app.push_history(HistoryItem {
|
||||
kind: HistoryKind::OutcomeUnknown,
|
||||
@@ -634,7 +664,6 @@ mod tests {
|
||||
entry_target: None,
|
||||
});
|
||||
let snapshot = rendered(&app, 110, 30);
|
||||
assert!(snapshot.contains("truncated"));
|
||||
assert!(snapshot.contains("stale"));
|
||||
assert!(snapshot.contains("OUTCOME UNKNOWN"));
|
||||
assert!(!snapshot.contains("validator"));
|
||||
@@ -649,7 +678,7 @@ mod tests {
|
||||
object: Some(Object {
|
||||
name: String::new(),
|
||||
description: Some("public root description".into()),
|
||||
interfaces: vec!["demo-interface".into()],
|
||||
interfaces: vec![root_reference("demo-interface")],
|
||||
r#ref: None,
|
||||
validator: Some(b"must-not-render".to_vec()),
|
||||
}),
|
||||
@@ -676,7 +705,8 @@ mod tests {
|
||||
}],
|
||||
};
|
||||
app.apply_interface(InterfaceObservation {
|
||||
reference: "demo-interface".into(),
|
||||
scope_ref: None,
|
||||
reference: root_reference("demo-interface"),
|
||||
descriptor: Some(descriptor.clone()),
|
||||
validator: Some(b"must-not-render".to_vec()),
|
||||
state: ObservationState::Fresh,
|
||||
@@ -696,7 +726,7 @@ mod tests {
|
||||
}
|
||||
let snapshot = rendered(&app, 140, 40);
|
||||
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("PROTOCOL ERROR"));
|
||||
assert!(snapshot.contains("TRANSPORT FAILURE"));
|
||||
@@ -704,7 +734,7 @@ mod tests {
|
||||
assert!(!snapshot.contains("must-not-render"));
|
||||
|
||||
let mut editor = ArgumentEditor::new(
|
||||
"demo-interface",
|
||||
root_reference("demo-interface"),
|
||||
descriptor.clone(),
|
||||
descriptor.operations[0].clone(),
|
||||
);
|
||||
@@ -740,8 +770,6 @@ mod tests {
|
||||
children: vec![],
|
||||
tree_state: ViewState::Unloaded,
|
||||
expanded: false,
|
||||
truncated: false,
|
||||
limit: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -761,7 +789,7 @@ mod tests {
|
||||
object: Some(Object {
|
||||
name: String::new(),
|
||||
description: None,
|
||||
interfaces: vec!["many".into()],
|
||||
interfaces: vec![root_reference("many")],
|
||||
r#ref: None,
|
||||
validator: None,
|
||||
}),
|
||||
@@ -780,7 +808,8 @@ mod tests {
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
app.apply_interface(InterfaceObservation {
|
||||
reference: "many".into(),
|
||||
scope_ref: None,
|
||||
reference: root_reference("many"),
|
||||
descriptor: Some(InterfaceDescriptor {
|
||||
format: INTERFACE_FORMAT_V1.into(),
|
||||
documentation: None,
|
||||
@@ -793,7 +822,7 @@ mod tests {
|
||||
app.focus = Focus::Operations;
|
||||
app.operation_cursor = app.operation_rows().len() - 1;
|
||||
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 {
|
||||
app.push_history(HistoryItem {
|
||||
@@ -815,6 +844,19 @@ mod tests {
|
||||
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]
|
||||
fn control_characters_and_large_bytes_are_bounded() {
|
||||
assert_eq!(sanitize("safe\u{1b}[31m"), "safe�[31m");
|
||||
@@ -832,8 +874,6 @@ mod tests {
|
||||
children: vec![],
|
||||
tree_state: ViewState::Unloaded,
|
||||
expanded: false,
|
||||
truncated: false,
|
||||
limit: 1,
|
||||
};
|
||||
assert_eq!(entry.path, "/");
|
||||
}
|
||||
|
||||
+122
-119
@@ -13,8 +13,8 @@ use wip_client::{
|
||||
DispatchedTransportFailure, OutcomeUnknownReason, PreparedRequest, RequestId, SecurityContext,
|
||||
SessionId,
|
||||
};
|
||||
use wip_https::Limits;
|
||||
use wip_https::http::{Request, Response};
|
||||
use wip_http::Limits;
|
||||
use wip_http::http::{Request, Response};
|
||||
use wip_protocol::{InterfaceDescriptor, TypeExpr, Value};
|
||||
|
||||
use crate::app::{
|
||||
@@ -68,18 +68,17 @@ pub trait Transport: Send + Sync + 'static {
|
||||
fn execute(&self, request: Self::Prepared) -> Result<Response<Vec<u8>>, TransportError>;
|
||||
}
|
||||
|
||||
/// HTTPS transport with redirects disabled and bounded response bodies.
|
||||
/// HTTP transport with redirects disabled and bounded response bodies.
|
||||
#[derive(Clone)]
|
||||
pub struct HttpTransport {
|
||||
agent: ureq::Agent,
|
||||
logical_endpoint: wip_https::Endpoint,
|
||||
transport_endpoint: wip_https::url::Url,
|
||||
authorization: Option<wip_https::http::HeaderValue>,
|
||||
endpoint: wip_http::Endpoint,
|
||||
authorization: Option<wip_http::http::HeaderValue>,
|
||||
max_response_bytes: usize,
|
||||
}
|
||||
|
||||
impl HttpTransport {
|
||||
/// Builds the default TLS transport and revalidates its immutable origins.
|
||||
/// Builds the HTTP transport and revalidates its immutable endpoint.
|
||||
pub fn new(config: &Config, max_response_bytes: usize) -> Result<Self, TransportError> {
|
||||
if max_response_bytes == 0 {
|
||||
return Err(TransportError {
|
||||
@@ -87,29 +86,18 @@ impl HttpTransport {
|
||||
detail: "transport response limit must be nonzero".into(),
|
||||
});
|
||||
}
|
||||
let logical_endpoint =
|
||||
wip_https::Endpoint::parse(config.logical_endpoint()).map_err(|_| TransportError {
|
||||
let endpoint =
|
||||
wip_http::Endpoint::parse(config.endpoint()).map_err(|_| TransportError {
|
||||
kind: TransportErrorKind::Disconnect,
|
||||
detail: "logical endpoint failed transport validation".into(),
|
||||
detail: "endpoint failed transport validation".into(),
|
||||
})?;
|
||||
let transport_endpoint =
|
||||
wip_https::url::Url::parse(config.transport_endpoint()).map_err(|_| {
|
||||
TransportError {
|
||||
kind: TransportErrorKind::Disconnect,
|
||||
detail: "transport endpoint failed validation".into(),
|
||||
}
|
||||
})?;
|
||||
validate_transport_origin(config, &logical_endpoint, &transport_endpoint)?;
|
||||
let authorization = config
|
||||
.authorization()
|
||||
.map(|secret| {
|
||||
wip_https::http::HeaderValue::from_str(secret.expose()).map_err(|_| {
|
||||
TransportError {
|
||||
kind: TransportErrorKind::Disconnect,
|
||||
detail:
|
||||
"authorization environment variable is not a valid HTTP header value"
|
||||
.into(),
|
||||
}
|
||||
wip_http::http::HeaderValue::from_str(secret.expose()).map_err(|_| TransportError {
|
||||
kind: TransportErrorKind::Disconnect,
|
||||
detail: "authorization environment variable is not a valid HTTP header value"
|
||||
.into(),
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
@@ -121,67 +109,24 @@ impl HttpTransport {
|
||||
.into();
|
||||
Ok(Self {
|
||||
agent,
|
||||
logical_endpoint,
|
||||
transport_endpoint,
|
||||
endpoint,
|
||||
authorization,
|
||||
max_response_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
fn effective_uri(&self, uri: &str) -> Result<String, TransportError> {
|
||||
let candidate = wip_https::url::Url::parse(uri).map_err(|_| TransportError {
|
||||
let candidate = wip_http::url::Url::parse(uri).map_err(|_| TransportError {
|
||||
kind: TransportErrorKind::Disconnect,
|
||||
detail: "refused a request with an invalid absolute URI".into(),
|
||||
})?;
|
||||
let route = self
|
||||
.logical_endpoint
|
||||
self.endpoint
|
||||
.recognize(&candidate)
|
||||
.ok_or_else(|| TransportError {
|
||||
kind: TransportErrorKind::Disconnect,
|
||||
detail: "refused a request outside the configured WIP origin and routes".into(),
|
||||
})?;
|
||||
Ok(self
|
||||
.transport_endpoint
|
||||
.join(route.relative_reference())
|
||||
.expect("validated endpoint joins a fixed route")
|
||||
.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_transport_origin(
|
||||
config: &Config,
|
||||
logical: &wip_https::Endpoint,
|
||||
transport: &wip_https::url::Url,
|
||||
) -> Result<(), TransportError> {
|
||||
let common_invalid = transport.query().is_some()
|
||||
|| transport.fragment().is_some()
|
||||
|| transport.host_str().is_none()
|
||||
|| !transport.username().is_empty()
|
||||
|| transport.password().is_some();
|
||||
let valid = if config.development_http() {
|
||||
let loopback = match transport.host() {
|
||||
Some(wip_https::url::Host::Ipv4(address)) => address.is_loopback(),
|
||||
Some(wip_https::url::Host::Ipv6(address)) => address.is_loopback(),
|
||||
Some(wip_https::url::Host::Domain(name)) => name.eq_ignore_ascii_case("localhost"),
|
||||
None => false,
|
||||
};
|
||||
let mut upgraded = transport.clone();
|
||||
let changed = upgraded.set_scheme("https").is_ok();
|
||||
!common_invalid
|
||||
&& transport.scheme() == "http"
|
||||
&& loopback
|
||||
&& changed
|
||||
&& &upgraded == logical.as_url()
|
||||
} else {
|
||||
!common_invalid && transport == logical.as_url()
|
||||
};
|
||||
if valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(TransportError {
|
||||
kind: TransportErrorKind::Disconnect,
|
||||
detail: "transport endpoint does not match the validated logical origin".into(),
|
||||
})
|
||||
Ok(candidate.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,10 +141,9 @@ impl Transport for HttpTransport {
|
||||
detail: "transport could not construct the configured request URI".into(),
|
||||
})?;
|
||||
if let Some(authorization) = &self.authorization {
|
||||
parts.headers.insert(
|
||||
wip_https::http::header::AUTHORIZATION,
|
||||
authorization.clone(),
|
||||
);
|
||||
parts
|
||||
.headers
|
||||
.insert(wip_http::http::header::AUTHORIZATION, authorization.clone());
|
||||
}
|
||||
Ok(Request::from_parts(parts, body))
|
||||
}
|
||||
@@ -242,7 +186,7 @@ fn classify_ureq_error(error: ureq::Error) -> TransportError {
|
||||
},
|
||||
_ => TransportError {
|
||||
kind: TransportErrorKind::Disconnect,
|
||||
detail: "HTTP/TLS transport failed".into(),
|
||||
detail: "HTTP transport failed".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -274,7 +218,7 @@ impl<T: Transport> Runtime<T> {
|
||||
);
|
||||
let session = client
|
||||
.open_session(
|
||||
config.logical_endpoint(),
|
||||
config.endpoint(),
|
||||
SecurityContext::new(config.security_context()),
|
||||
)
|
||||
.map_err(|error| RuntimeError(error.to_string()))?;
|
||||
@@ -282,7 +226,7 @@ impl<T: Transport> Runtime<T> {
|
||||
Ok(Self {
|
||||
client,
|
||||
session,
|
||||
app: App::new(config.transport_endpoint()),
|
||||
app: App::new(config.endpoint()),
|
||||
transport: Arc::new(transport),
|
||||
sender,
|
||||
receiver,
|
||||
@@ -304,8 +248,7 @@ impl<T: Transport> Runtime<T> {
|
||||
/// Starts or restarts observation at Worldspace `/`.
|
||||
pub fn connect(&mut self) {
|
||||
self.app.connection = ConnectionState::Connecting;
|
||||
let limit = self.app.default_tree_limit;
|
||||
match self.client.prepare_tree(&self.session, "/", 1, limit) {
|
||||
match self.client.refresh_children(&self.session, "/") {
|
||||
Ok(request) => self.dispatch(request, InFlightKind::Tree("/".into()), false),
|
||||
Err(error) => self.local_error("connect", error),
|
||||
}
|
||||
@@ -318,6 +261,9 @@ impl<T: Transport> Runtime<T> {
|
||||
for action in actions {
|
||||
quit |= self.perform(action);
|
||||
}
|
||||
if !quit {
|
||||
self.reconcile_visible_lookahead();
|
||||
}
|
||||
quit
|
||||
}
|
||||
|
||||
@@ -348,26 +294,19 @@ impl<T: Transport> Runtime<T> {
|
||||
|
||||
fn perform(&mut self, action: Action) -> bool {
|
||||
match action {
|
||||
Action::Expand { path, limit } => {
|
||||
match self.client.prepare_tree(&self.session, &path, 1, limit) {
|
||||
Ok(request) => self.dispatch(request, InFlightKind::Tree(path), false),
|
||||
Err(error) => self.local_error("tree expansion", error),
|
||||
}
|
||||
}
|
||||
Action::Refresh { path, limit } => {
|
||||
match self.client.prepare_fetch(&self.session, &path) {
|
||||
Ok(request) => {
|
||||
self.dispatch(request, InFlightKind::Object(path.clone()), false)
|
||||
}
|
||||
Err(error) => self.local_error("Entry refresh", error),
|
||||
}
|
||||
match self.client.prepare_tree(&self.session, &path, 1, limit) {
|
||||
Ok(request) => self.dispatch(request, InFlightKind::Tree(path), false),
|
||||
Err(error) => self.local_error("subtree refresh", error),
|
||||
}
|
||||
}
|
||||
Action::Expand { path } => match self.client.refresh_children(&self.session, &path) {
|
||||
Ok(request) => self.dispatch(request, InFlightKind::Tree(path), false),
|
||||
Err(error) => self.local_error("tree expansion", error),
|
||||
},
|
||||
Action::Refresh { path } => match self.client.refresh_children(&self.session, &path) {
|
||||
Ok(request) => self.dispatch(request, InFlightKind::Tree(path), false),
|
||||
Err(error) => self.local_error("subtree refresh", error),
|
||||
},
|
||||
Action::FetchInterface(reference) => {
|
||||
match self.client.prepare_interface(&self.session, &reference) {
|
||||
match self
|
||||
.client
|
||||
.prepare_interface(&self.session, reference.clone())
|
||||
{
|
||||
Ok(request) => {
|
||||
self.dispatch(request, InFlightKind::Interface(reference), false)
|
||||
}
|
||||
@@ -402,7 +341,7 @@ impl<T: Transport> Runtime<T> {
|
||||
Action::Reconnect => self.connect(),
|
||||
Action::NavigateEntry(path) => {
|
||||
self.app.navigate_entry(path.clone());
|
||||
match self.client.prepare_fetch(&self.session, &path) {
|
||||
match self.client.refresh_object(&self.session, &path) {
|
||||
Ok(request) => self.dispatch(request, InFlightKind::Object(path), false),
|
||||
Err(error) => self.local_error("Entry navigation", error),
|
||||
}
|
||||
@@ -412,6 +351,41 @@ impl<T: Transport> Runtime<T> {
|
||||
false
|
||||
}
|
||||
|
||||
fn reconcile_visible_lookahead(&mut self) {
|
||||
if self.app.connection != ConnectionState::Connected {
|
||||
return;
|
||||
}
|
||||
let paths = self.app.visible_paths();
|
||||
let references = self.app.visible_interface_references();
|
||||
for path in paths {
|
||||
match self.client.ensure_children_observed(&self.session, &path) {
|
||||
Ok(Some(request)) => {
|
||||
self.dispatch(request, InFlightKind::Tree(path), false);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
self.local_error("observation lookahead", error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for reference in references {
|
||||
match self
|
||||
.client
|
||||
.ensure_interface(&self.session, reference.clone())
|
||||
{
|
||||
Ok(Some(request)) => {
|
||||
self.dispatch(request, InFlightKind::Interface(reference), false);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
self.local_error("interface lookahead", error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch(&mut self, request: PreparedRequest, kind: InFlightKind, operation: bool) {
|
||||
let id = request.id;
|
||||
self.app.request_started(id.get(), kind.clone());
|
||||
@@ -576,17 +550,22 @@ impl<T: Transport> Runtime<T> {
|
||||
}
|
||||
}
|
||||
self.reconcile_authoritative();
|
||||
self.reconcile_visible_lookahead();
|
||||
}
|
||||
|
||||
fn apply_completion(&mut self, completion: Completion, kind: Option<&InFlightKind>) {
|
||||
match completion {
|
||||
Completion::Object(observation) => self.app.apply_object(observation),
|
||||
Completion::Tree(observation) => {
|
||||
let root = observation.path.clone();
|
||||
self.app.apply_tree(observation);
|
||||
self.sync_all_objects();
|
||||
if root == "/" {
|
||||
self.app.connection = ConnectionState::Connected;
|
||||
Completion::Object(observation) => {
|
||||
let path = observation.path.clone();
|
||||
self.app.apply_object(observation);
|
||||
if matches!(kind, Some(InFlightKind::Tree(_))) {
|
||||
if let Some(observation) = self.client.tree(&self.session, &path).cloned() {
|
||||
self.app.apply_tree(observation);
|
||||
}
|
||||
self.sync_all_objects();
|
||||
if path == "/" {
|
||||
self.app.connection = ConnectionState::Connected;
|
||||
}
|
||||
}
|
||||
}
|
||||
Completion::Interface(observation) => self.app.apply_interface(observation),
|
||||
@@ -626,8 +605,9 @@ impl<T: Transport> Runtime<T> {
|
||||
|
||||
fn push_call_history(&mut self, record: &wip_client::CallRecord) {
|
||||
let key = format!(
|
||||
"({}, {}) on {}",
|
||||
record.context.interface().reference,
|
||||
"(scope={}, name={}, {}) on {}",
|
||||
sanitize(&record.context.interface().reference.scope),
|
||||
sanitize(&record.context.interface().reference.name),
|
||||
record.context.operation().name,
|
||||
record.context.object().path
|
||||
);
|
||||
@@ -708,9 +688,13 @@ impl<T: Transport> Runtime<T> {
|
||||
|
||||
fn push_client_error(&mut self, kind: Option<&InFlightKind>, error: &ClientError) {
|
||||
let title = match kind {
|
||||
Some(InFlightKind::Object(path)) => format!("fetch {path}"),
|
||||
Some(InFlightKind::Object(path)) => format!("observe {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(),
|
||||
None => "request".into(),
|
||||
};
|
||||
@@ -889,8 +873,7 @@ fn collect_entries_at(
|
||||
/// Standard bounded wire policy used by the executable and integration tests.
|
||||
#[must_use]
|
||||
pub fn default_wire_limits() -> Limits {
|
||||
Limits::new(256 * 1024, 2 * 1024 * 1024, 64, 64)
|
||||
.expect("static runtime wire limits are nonzero")
|
||||
Limits::new(256 * 1024, 2 * 1024 * 1024, 64).expect("static runtime wire limits are nonzero")
|
||||
}
|
||||
|
||||
/// Runs the interactive executable using process configuration.
|
||||
@@ -1017,6 +1000,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_preserves_the_configured_endpoint_scheme_for_requests() {
|
||||
for scheme in ["http", "https"] {
|
||||
let configured = format!("{scheme}://example.test/wip");
|
||||
let config = Config::parse([configured], None, None, |_| None).unwrap();
|
||||
let transport = HttpTransport::new(&config, 1024).unwrap();
|
||||
let endpoint = wip_http::Endpoint::parse(config.endpoint()).unwrap();
|
||||
let expected = endpoint.route(wip_http::Route::Observe).to_string();
|
||||
let request = Request::builder()
|
||||
.method("POST")
|
||||
.uri(&expected)
|
||||
.body(Vec::new())
|
||||
.unwrap();
|
||||
|
||||
let prepared = transport.prepare(request).unwrap();
|
||||
|
||||
assert_eq!(prepared.uri().to_string(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorization_is_attached_only_after_exact_origin_and_route_validation() {
|
||||
let config = Config::parse(
|
||||
@@ -1029,22 +1032,22 @@ mod tests {
|
||||
let transport = HttpTransport::new(&config, 1024).unwrap();
|
||||
let off_origin = Request::builder()
|
||||
.method("POST")
|
||||
.uri("https://attacker.test/wip/v1/fetch")
|
||||
.uri("https://attacker.test/wip/v1/observe")
|
||||
.body(Vec::new())
|
||||
.unwrap();
|
||||
let error = transport.prepare(off_origin).unwrap_err();
|
||||
assert!(error.detail.contains("outside"));
|
||||
assert!(!error.detail.contains("sensitive"));
|
||||
|
||||
let endpoint = wip_https::Endpoint::parse(config.logical_endpoint()).unwrap();
|
||||
let endpoint = wip_http::Endpoint::parse(config.endpoint()).unwrap();
|
||||
let valid = Request::builder()
|
||||
.method("POST")
|
||||
.uri(endpoint.route(wip_https::Route::Fetch).as_str())
|
||||
.uri(endpoint.route(wip_http::Route::Observe).as_str())
|
||||
.body(Vec::new())
|
||||
.unwrap();
|
||||
let prepared = transport.prepare(valid).unwrap();
|
||||
assert_eq!(
|
||||
prepared.headers()[wip_https::http::header::AUTHORIZATION],
|
||||
prepared.headers()[wip_http::http::header::AUTHORIZATION],
|
||||
"Bearer sensitive"
|
||||
);
|
||||
}
|
||||
@@ -1052,7 +1055,7 @@ mod tests {
|
||||
#[test]
|
||||
fn transport_error_messages_do_not_echo_underlying_data() {
|
||||
let error = classify_ureq_error(ureq::Error::BadUri("secret-token".into()));
|
||||
assert_eq!(error.detail, "HTTP/TLS transport failed");
|
||||
assert_eq!(error.detail, "HTTP transport failed");
|
||||
assert!(!error.detail.contains("secret-token"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,6 @@ pub fn translate_key(event: KeyEvent, editing: bool) -> Option<InputKey> {
|
||||
KeyCode::Char('l') => InputKey::Right,
|
||||
KeyCode::Char('r') => InputKey::Refresh,
|
||||
KeyCode::Char('R') => InputKey::Reconnect,
|
||||
KeyCode::Char('+') | KeyCode::Char('=') => InputKey::IncreaseLimit,
|
||||
KeyCode::Char('c') => InputKey::CancelRead,
|
||||
KeyCode::Char('?') => InputKey::Help,
|
||||
KeyCode::Char('q') => InputKey::Quit,
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tempfile::tempdir;
|
||||
use wip_fs_server::{AccessMode, ServerConfig, TransportSecurity, WipFsServer};
|
||||
use wip_fs_server::{AccessMode, ServerConfig, WipFsServer};
|
||||
use wip_tui::app::{Focus, HistoryKind, InputKey, Modal, ViewState};
|
||||
use wip_tui::config::Config;
|
||||
use wip_tui::runtime::{HttpTransport, Runtime, default_wire_limits};
|
||||
@@ -22,7 +22,6 @@ fn real_http_filesystem_workflow_is_lazy_validator_safe_and_symlink_confined() {
|
||||
|
||||
let mut server_config = ServerConfig::for_root(temporary.path());
|
||||
server_config.access = AccessMode::ReadWrite;
|
||||
server_config.transport_security = TransportSecurity::DevelopmentHttp;
|
||||
let bound = Arc::new(
|
||||
WipFsServer::new(server_config)
|
||||
.expect("build filesystem server")
|
||||
@@ -36,13 +35,8 @@ fn real_http_filesystem_workflow_is_lazy_validator_safe_and_symlink_confined() {
|
||||
thread::spawn(move || bound.serve_until(shutdown))
|
||||
};
|
||||
let endpoint = format!("http://127.0.0.1:{}/wip", bound.local_addr().port());
|
||||
let config = Config::parse(
|
||||
[endpoint, "--allow-http".into()],
|
||||
None,
|
||||
Some("filesystem-test".into()),
|
||||
|_| None,
|
||||
)
|
||||
.expect("development config");
|
||||
let config = Config::parse([endpoint], None, Some("filesystem-test".into()), |_| None)
|
||||
.expect("development config");
|
||||
let transport = HttpTransport::new(&config, default_wire_limits().max_response_bytes())
|
||||
.expect("HTTP transport");
|
||||
let mut runtime = Runtime::new(&config, transport).expect("runtime");
|
||||
@@ -53,19 +47,24 @@ fn real_http_filesystem_workflow_is_lazy_validator_safe_and_symlink_confined() {
|
||||
assert!(runtime.app().entries.contains_key("/hello.txt"));
|
||||
assert!(runtime.app().entries.contains_key("/nested"));
|
||||
assert!(runtime.app().entries.contains_key("/outside-link"));
|
||||
let empty_directory = &runtime.app().entries["/nested"];
|
||||
assert_eq!(empty_directory.tree_state, ViewState::Fresh);
|
||||
assert!(empty_directory.children.is_empty());
|
||||
assert!(!empty_directory.expanded);
|
||||
|
||||
// Keyboard-select the file and load its read interface and descriptor.
|
||||
runtime.app_mut().selected_path = "/hello.txt".into();
|
||||
runtime.app_mut().focus = Focus::Operations;
|
||||
runtime.app_mut().operation_cursor = 0;
|
||||
assert!(!runtime.handle_key(InputKey::Enter));
|
||||
settle(&mut runtime);
|
||||
let read_reference = runtime.app().entries["/hello.txt"]
|
||||
// Visible Objects have their interface descriptors prefetched before selection.
|
||||
let file_interfaces = runtime.app().entries["/hello.txt"]
|
||||
.object
|
||||
.as_ref()
|
||||
.expect("file object")
|
||||
.interfaces[0]
|
||||
.interfaces
|
||||
.clone();
|
||||
assert!(!file_interfaces.is_empty());
|
||||
for reference in &file_interfaces {
|
||||
assert_eq!(runtime.app().interfaces[reference].state, ViewState::Fresh);
|
||||
assert!(runtime.app().interfaces[reference].descriptor.is_some());
|
||||
}
|
||||
let read_reference = file_interfaces[0].clone();
|
||||
assert_eq!(
|
||||
runtime.app().interfaces[&read_reference]
|
||||
.descriptor
|
||||
@@ -76,9 +75,13 @@ fn real_http_filesystem_workflow_is_lazy_validator_safe_and_symlink_confined() {
|
||||
"read"
|
||||
);
|
||||
|
||||
// Descriptor-driven read editor, explicit confirmation, and execution.
|
||||
// Keyboard-select the already prefetched read operation.
|
||||
runtime.app_mut().selected_path = "/hello.txt".into();
|
||||
runtime.app_mut().focus = Focus::Operations;
|
||||
runtime.app_mut().operation_cursor = 1;
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
assert!(!runtime.handle_key(InputKey::Enter));
|
||||
|
||||
// Descriptor-driven read editor, explicit confirmation, and execution.
|
||||
assert!(matches!(runtime.app().modal, Modal::Editor(_)));
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
assert!(matches!(runtime.app().modal, Modal::Confirm { .. }));
|
||||
@@ -91,18 +94,11 @@ fn real_http_filesystem_workflow_is_lazy_validator_safe_and_symlink_confined() {
|
||||
.contains("68656c6c6f20776f726c64")
|
||||
);
|
||||
|
||||
// Calls conservatively stale the object even for a read operation; refresh
|
||||
// before constructing the separately confirmed write call.
|
||||
runtime.app_mut().focus = Focus::Tree;
|
||||
runtime.handle_key(InputKey::Refresh);
|
||||
settle(&mut runtime);
|
||||
assert_eq!(runtime.app().entries["/hello.txt"].state, ViewState::Fresh);
|
||||
runtime.app_mut().focus = Focus::Operations;
|
||||
|
||||
// Load the separately-qualified write interface and replace with the
|
||||
// Use the separately-qualified prefetched write interface and replace with the
|
||||
// automatically attached observed validator.
|
||||
runtime.app_mut().operation_cursor = 2;
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
settle(&mut runtime);
|
||||
runtime.app_mut().operation_cursor = 3;
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
set_only_field(&mut runtime, "dXBkYXRlZA=="); // "updated"
|
||||
@@ -112,15 +108,11 @@ fn real_http_filesystem_workflow_is_lazy_validator_safe_and_symlink_confined() {
|
||||
std::fs::read(temporary.path().join("hello.txt")).expect("updated file"),
|
||||
b"updated"
|
||||
);
|
||||
assert_eq!(runtime.app().entries["/hello.txt"].state, ViewState::Stale);
|
||||
|
||||
// Reobserve, prepare a replacement from that observation, then mutate the
|
||||
// file externally before explicit execution. The stale validator is rejected,
|
||||
// the Entry becomes stale, and no automatic call is created.
|
||||
runtime.app_mut().focus = Focus::Tree;
|
||||
runtime.handle_key(InputKey::Refresh);
|
||||
settle(&mut runtime);
|
||||
assert_eq!(runtime.app().entries["/hello.txt"].state, ViewState::Fresh);
|
||||
|
||||
// Prepare another replacement using the post-operation validator, then mutate
|
||||
// the file externally before explicit execution. The stale validator is rejected,
|
||||
// the Entry becomes stale, and no automatic call is created.
|
||||
runtime.app_mut().focus = Focus::Operations;
|
||||
runtime.app_mut().operation_cursor = 3;
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
@@ -5,8 +5,8 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use tempfile::tempdir;
|
||||
use wip_fs_server::{ServerConfig, WipFsServer};
|
||||
use wip_https::encode_protocol_error_response;
|
||||
use wip_https::http::{Request, Response};
|
||||
use wip_http::encode_protocol_error_response;
|
||||
use wip_http::http::{Request, Response};
|
||||
use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction};
|
||||
use wip_tui::app::{ConnectionState, Focus, HistoryKind, InputKey, Modal, ViewState};
|
||||
use wip_tui::config::Config;
|
||||
@@ -50,7 +50,7 @@ impl Transport for ScriptedTransport {
|
||||
detail: "scripted local conversion failure".into(),
|
||||
});
|
||||
}
|
||||
if self.fail_tree_prepare && request.uri().path().ends_with("/v1/fetch_tree") {
|
||||
if self.fail_tree_prepare && request.uri().path().ends_with("/v1/observe") {
|
||||
return Err(TransportError {
|
||||
kind: TransportErrorKind::Disconnect,
|
||||
detail: "scripted tree conversion failure".into(),
|
||||
@@ -61,7 +61,7 @@ impl Transport for ScriptedTransport {
|
||||
|
||||
fn execute(&self, request: Self::Prepared) -> Result<Response<Vec<u8>>, TransportError> {
|
||||
let sequence = self.sequence.fetch_add(1, Ordering::AcqRel);
|
||||
if request.uri().path().ends_with("/v1/fetch_tree")
|
||||
if request.uri().path().ends_with("/v1/observe")
|
||||
&& self
|
||||
.root_tree_protocol_failures
|
||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |remaining| {
|
||||
@@ -70,7 +70,7 @@ impl Transport for ScriptedTransport {
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(encode_protocol_error_response(
|
||||
ProtocolInteraction::FetchTree,
|
||||
ProtocolInteraction::Observe,
|
||||
&ProtocolError {
|
||||
code: ProtocolErrorCode::Internal,
|
||||
message: "scripted startup failure".into(),
|
||||
@@ -80,9 +80,9 @@ impl Transport for ScriptedTransport {
|
||||
.expect("encode tree failure"));
|
||||
}
|
||||
if self.delayed_after_startup && sequence > 0 {
|
||||
// Each refresh starts object then tree. Make the first pair lose to
|
||||
// the second pair to exercise exact RequestId ownership.
|
||||
let delay = if matches!(sequence, 1 | 2) { 80 } else { 5 };
|
||||
// Delay the first refresh so the second wins and exercises exact
|
||||
// RequestId ownership.
|
||||
let delay = if sequence == 1 { 80 } else { 5 };
|
||||
thread::sleep(Duration::from_millis(delay));
|
||||
}
|
||||
if self.panic_calls && request.uri().path().ends_with("/v1/call_operation") {
|
||||
|
||||
Reference in New Issue
Block a user