diff --git a/CHANGELOG.md b/CHANGELOG.md index 96de104..46d1b26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,20 @@ ## [Unreleased] +### Added + +- Add the `host` WIT interface (`fetch`) and wire it into `world plugin` as an import, + so a plugin component can no longer instantiate without a host that supplies network + access **[BREAKING]** +- Re-export the `Guest` trait and `export!` macro for the `data-provider` interface, and + add a `host_fetch` wrapper around the generated `fetch` import, so a downstream plugin + can implement and export the world using this crate's own canonical types +- Bump `INTERFACE_VERSION` to `2.0` **[BREAKING]** + ### Documentation - Add badges, CONTRIBUTING, CODE_OF_CONDUCT, and RELEASING +- Document implementing/exporting the `Guest` trait and using `host_fetch` in + `docs/plugin-authoring.md` ## [0.1.0] - 2026-07-20 diff --git a/README.md b/README.md index 551dce8..2333fa4 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ See [`Version::accepts`]. ```toml [dependencies] -fulltime-plugin-api = "0.1" +fulltime-plugin-api = "0.2" ``` ```rust diff --git a/docs/plugin-authoring.md b/docs/plugin-authoring.md index 18e709f..ee8ceb6 100644 --- a/docs/plugin-authoring.md +++ b/docs/plugin-authoring.md @@ -50,6 +50,31 @@ instead of letting an upstream failure surface as an unhandled trap: - `schema-mapping-failure` — the upstream response can't be represented in the canonical schema. +### 2b. The `host` interface (network access) + +Plugins have no direct network access — a WASM component can't open a socket on its own. +Every upstream HTTP call goes through the `host` interface's `fetch` import, which +`world plugin` requires (`import host;`). Call this crate's [`host_fetch`] wrapper rather +than the raw generated binding: + +```rust,ignore +let body: Vec = fulltime_plugin_api::host_fetch("https://api.openligadb.de/getbltable/bl1/2024")?; +``` + +`host_fetch` only links and behaves correctly when compiled as part of a real `wasm32` +component instantiated by a host implementing `host.fetch` — it has no behavior of its own +outside that. Two consequences for how you structure a plugin: + +- Gate calls to it behind `#[cfg(target_arch = "wasm32")]` (or an equivalent feature flag). +- For native unit/integration tests, define your own small injectable trait (a `Fetcher` + taking a URL and returning bytes or an error) that your `wasm32` build implements by + delegating to `host_fetch`, and that your tests implement with fixture data. This mirrors + the pattern `Plugins/Bundesliga` uses. + +A non-2xx status or any other transport failure comes back as this crate's +[`NetworkFailure`] — the same type your `data-provider` operations already return inside +[`ProviderError::NetworkFailure`]. + ### 3. The manifest Every plugin ships a TOML manifest, parsed at load time by [`Manifest::parse`]: @@ -58,7 +83,7 @@ Every plugin ships a TOML manifest, parsed at load time by [`Manifest::parse`]: id = "bundesliga" version = "0.1.0" schema_version = "1.0" -interface_version = "1.0" +interface_version = "2.0" network_hosts = ["api.openligadb.de"] ``` @@ -86,19 +111,51 @@ When either version changes: - **Major bump**: breaking (removed/renamed field, changed function signature). Plugins must be rebuilt and republish their manifest's target version. +## Implementing and exporting the world + +This crate re-exports what `wit_bindgen::generate!` produces for the `data-provider` +export so you don't regenerate your own (nominally incompatible) copy of the canonical +types from a vendored WIT file: + +- [`Guest`] — the trait to implement, one method per operation in the table above, using + this crate's own `Team`/`Fixture`/`Standings`/`Competition`/`ProviderError` types + directly. +- [`export!`] — the macro that exports your `Guest` implementation as the component's + `data-provider` interface. + +```rust,ignore +struct MyPlugin; + +impl fulltime_plugin_api::Guest for MyPlugin { + fn list_competitions() -> Result, fulltime_plugin_api::ProviderError> { + // ... + } + // fetch_fixtures, fetch_results, fetch_standings, fetch_metadata ... +} + +fulltime_plugin_api::export!(MyPlugin); +``` + ## Getting started 1. Add this crate as a dependency: ```toml [dependencies] - fulltime-plugin-api = "0.1" + fulltime-plugin-api = "0.2" ``` -2. Implement the `data-provider` world's exported interface against your upstream data - source, mapping its response shape into the canonical schema types. -3. Write your `manifest.toml` declaring the network hosts you call. -4. Build to a WASM component target and load it against the host runtime in `Apps/rust`. +2. Implement [`Guest`] against your upstream data source, mapping its response shape into + the canonical schema types, and calling [`host_fetch`] for every upstream request. +3. Call [`export!`] with your implementation. +4. Write your `manifest.toml` declaring the network hosts you call and `interface_version + = "2.0"`. +5. Build to a WASM component target and load it against the host runtime in `Apps/rust`. [`Manifest::parse`]: https://docs.rs/fulltime-plugin-api/latest/fulltime_plugin_api/struct.Manifest.html#method.parse [`SCHEMA_VERSION`]: https://docs.rs/fulltime-plugin-api/latest/fulltime_plugin_api/constant.SCHEMA_VERSION.html [`INTERFACE_VERSION`]: https://docs.rs/fulltime-plugin-api/latest/fulltime_plugin_api/constant.INTERFACE_VERSION.html [`Version::accepts`]: https://docs.rs/fulltime-plugin-api/latest/fulltime_plugin_api/struct.Version.html#method.accepts +[`Guest`]: https://docs.rs/fulltime-plugin-api/latest/fulltime_plugin_api/trait.Guest.html +[`export!`]: https://docs.rs/fulltime-plugin-api/latest/fulltime_plugin_api/macro.export.html +[`host_fetch`]: https://docs.rs/fulltime-plugin-api/latest/fulltime_plugin_api/fn.host_fetch.html +[`NetworkFailure`]: https://docs.rs/fulltime-plugin-api/latest/fulltime_plugin_api/struct.NetworkFailure.html +[`ProviderError::NetworkFailure`]: https://docs.rs/fulltime-plugin-api/latest/fulltime_plugin_api/enum.ProviderError.html#variant.NetworkFailure diff --git a/openspec/changes/add-host-fetch-capability/.openspec.yaml b/openspec/changes/add-host-fetch-capability/.openspec.yaml new file mode 100644 index 0000000..29e56d8 --- /dev/null +++ b/openspec/changes/add-host-fetch-capability/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-20 diff --git a/openspec/changes/add-host-fetch-capability/design.md b/openspec/changes/add-host-fetch-capability/design.md new file mode 100644 index 0000000..a7421de --- /dev/null +++ b/openspec/changes/add-host-fetch-capability/design.md @@ -0,0 +1,159 @@ +## Context + +`wit/data-provider.wit` defines `world plugin { export data-provider; }` — no imports. A +plugin component built against this world needs nothing from its host to instantiate, +which contradicts the umbrella plugin architecture's premise (plugins are sandboxed, with +no direct network access; all upstream calls go through a host-provided `fetch` +capability) and this crate's own `docs/plugin-authoring.md`, which already describes that +capability as if it exists. + +Separately, `src/bindings.rs` runs `wit_bindgen::generate!` inside a private `mod +bindings;` and `src/lib.rs` only re-exports two of its interfaces (`types`, `errors`) as +plain Rust structs/enums. Nothing exposes the `Guest` trait or `export!` macro +`wit_bindgen::generate!` also produces for the `data-provider` export — the parts a plugin +actually needs to *implement* the world, as opposed to just referencing its data shapes. +`Plugins/Bundesliga` hit this directly: its `src/provider.rs` implements the five +operations as plain functions with matching signatures, not as a real `Guest` impl, +because there was nothing to implement against. + +## Goals / Non-Goals + +**Goals:** +- Define the `host.fetch` WIT import and wire it into `world plugin`. +- Expose enough of this crate's generated bindings that a downstream plugin can depend on + it as an ordinary Rust library and get a real `Guest` trait, `export!` macro, and a safe + wrapper around calling `fetch`, without regenerating its own (nominally incompatible) + copy of the same types. +- Keep the fix backward-referenceable: `Plugins/Bundesliga`'s existing `Fetcher` + trait/`provider.rs` functions should map cleanly onto the new `Guest` trait once that + repo does its own follow-up migration (out of scope here, tracked there). + +**Non-Goals:** +- Implementing the host side of `fetch` (belongs to `Apps/rust`'s `plugin-host-runtime` + change). +- Migrating `Plugins/Bundesliga` onto the new bindings (follow-up in that repo). +- Publishing a new crates.io release as part of this change (see proposal.md's Impact + section). + +## Decisions + +**`host.fetch` is GET-only, returns raw bytes, and reuses `errors.network-failure` for its +failure case rather than a new error type.** +Rationale: every current and near-term data-provider operation (`openligadb` and the +umbrella design's other planned providers) only needs GET. Returning raw `list` keeps +the host interface agnostic to response format — deserialization stays the plugin's job, +consistent with `errors`' existing separation between transport failures (`network-failure`) +and mapping failures (`schema-mapping-failure`). A non-2xx HTTP status is folded into +`network-failure` (its `message` field carries the status/detail) rather than adding a +distinct variant now; if a plugin later needs to branch on status code specifically, that's +an additive change to the `host` interface, not a reason to block this one. + +```wit +interface host { + use errors.{network-failure}; + + /// Fetches the response body for an HTTP GET request to `url`, via the host. + /// + /// The host scopes this to hosts declared in the plugin's manifest + /// `network_hosts` field; a request to an undeclared host fails as a + /// `network-failure`, not a distinct permission-denied variant, since from the + /// plugin's perspective both are "the request didn't succeed." + fetch: func(url: string) -> result, network-failure>; +} + +world plugin { + import host; + export data-provider; +} +``` + +**Alternative considered:** a `Fetcher`-style resource/trait-object import (mirroring +`Plugins/Bundesliga`'s current Rust-side `Fetcher` trait) instead of a single free +function. Rejected: WIT resources add instantiation complexity (the host would need to +construct and pass a resource handle) for no benefit here — there is exactly one +operation, and a free function is the simplest shape that satisfies it. + +**`INTERFACE_VERSION` bumps from `1.0` to `2.0`, not `1.1`.** +Rationale: `Version::accepts` (major equal, host minor ≥ plugin minor) models the export +side of compatibility — the host is a superset of the functions a plugin expects to call +*on itself*. Adding a required import inverts that relationship for a new axis: a plugin +built against the old `1.x` world needs nothing from the host to instantiate; a plugin +built against the new world requires the host to supply `fetch`, and there is no way for +an old host to satisfy that later without changing its own code. That is exactly what a +major version bump communicates, even though the *mechanism* enforcing it differs — a +version mismatch is caught by the manifest check in Rust before instantiation, whereas a +missing WIT import is caught by the component linker at instantiation time with a less +informative error. Bumping major means a host built for `1.x` correctly refuses a `2.x` +plugin manifest before ever attempting to instantiate it, rather than surfacing a raw +linker error. +**Open question, not resolved here:** whether `Version::accepts`'s major-match rule should +eventually distinguish "export-shape compatible" from "import-requirements compatible" as +two separate fields, given they're conceptually different axes that happen to share one +version number today. Flagging for the `fulltime-plugin-api` maintainer; not blocking this +change, since collapsing them into one major bump is still correct, just coarser than it +could be. + +**`bindings` stays a private module; `export!`, `Guest`, and the generated `exports` tree +are re-exported — more of the generated surface than originally planned.** +The `mod bindings;` module itself stays private, and `pub use bindings::export;` plus a +`Guest` alias were the intended minimal surface. In practice, `wit_bindgen`'s single-arg +`export!($ty)` macro expands to `self::export!($ty with_types_in self)` — `self` resolves +to the *caller's* module, so it only compiles when the macro is invoked inside this crate +itself. A downstream crate must use the macro's `with_types_in ` form +(`fulltime_plugin_api::export!(MyPlugin with_types_in fulltime_plugin_api)`), and that form +requires the full generated `exports::fulltime::plugin_api::data_provider` module path to +be reachable at that root — which means `exports` itself has to be `pub use`d, not just the +`Guest` trait pulled out of it. Verified end-to-end by cross-compiling a scratch crate +depending on this one for `wasm32-wasip2`; the single-item re-export alone produced +`cannot find export in self`, `with_types_in` alone then produced `cannot find exports in +fulltime_plugin_api`, and only re-exporting `exports` as well resolved both. This exposes +more of `wit_bindgen`'s generated internals (the full `exports` module tree, not just +`Guest`) than the minimal-surface goal above intended — an acceptable trade because the +alternative (no working `export!` for any downstream crate) defeats the point of this +change entirely, but worth a future `wit-bindgen` version bump checking this path shape +hasn't changed. + +**`host_fetch` is a thin wrapper, not a trait.** +Rationale: `wit_bindgen::generate!`'s import binding for `host.fetch` is already a plain +function once compiled for `wasm32` with the component model; wrapping it in +`pub fn host_fetch(url: &str) -> Result, NetworkFailure>` just gives it this +crate's own `NetworkFailure` type at the boundary (translating from the generated +`errors::network-failure` type, which is the same struct after the `errors` interface +re-export, so this is close to a no-op today, but keeps the wrapper's signature stable if +the WIT error shape changes later). +**Consequence a downstream plugin must handle itself, documented in +`docs/plugin-authoring.md`:** `host_fetch` only links successfully when the crate is +compiled for `wasm32` as part of a real component instantiated by a host implementing +`host.fetch` — call it from `#[cfg(target_arch = "wasm32")]`-gated code, and keep a +separate injectable seam (as `Plugins/Bundesliga`'s `Fetcher` trait already does) for +native unit/integration tests, with a `wasm32`-only impl of that seam delegating to +`host_fetch`. This crate's own CI does not call `host_fetch` anywhere, so this change does +not by itself require any native-vs-wasm build split in this repo — only in a plugin that +uses it. + +## Risks / Trade-offs + +- [Every plugin currently built against the `1.x` world (in practice, only + `Plugins/Bundesliga`'s in-progress, non-exported placeholder) needs a follow-up + migration once this ships] → No published plugin exists yet; `Plugins/Bundesliga`'s + migration is tracked as follow-up work in that repo, not blocking this change. +- [`host_fetch` looks callable natively but only actually links inside a real wasm + component] → Documented explicitly in `docs/plugin-authoring.md` and in the wrapper's + own doc comment; the design's `#[cfg(target_arch = "wasm32")]` guidance is the mitigation. +- [Collapsing "export compatible" and "import compatible" into one `INTERFACE_VERSION` + major number is coarser than strictly necessary] → Flagged as an open question above + rather than solved speculatively; revisit if a future import-only or export-only change + makes the coupling actually costly. + +## Migration Plan + +Not applicable in the deployment sense — no host runtime consumes this crate yet. The +migration that matters is source-level: `Plugins/Bundesliga` (and any future plugin) +switches from a self-defined `Fetcher`-style seam to this crate's `Guest`/`export!`/ +`host_fetch`, tracked as follow-up work in that repo once this change is merged. + +## Open Questions + +- Should `Version::accepts` eventually split into separate export-compatibility and + import-compatibility checks? Not resolved here (see the `INTERFACE_VERSION` decision + above) — flagging for the maintainer rather than deciding unilaterally in this change. diff --git a/openspec/changes/add-host-fetch-capability/proposal.md b/openspec/changes/add-host-fetch-capability/proposal.md new file mode 100644 index 0000000..f3d4026 --- /dev/null +++ b/openspec/changes/add-host-fetch-capability/proposal.md @@ -0,0 +1,58 @@ +## Why + +The `data-provider` WIT world currently only `export`s the plugin interface — it defines +no host-provided `fetch` import, even though the umbrella plugin architecture and this +crate's own `docs/plugin-authoring.md` require plugins to have no direct network access +and to route all upstream calls through a host `fetch` capability. Separately, this +crate's generated bindings (`mod bindings;`) are private, so a downstream plugin has no +way to obtain the `Guest` trait or `export!` macro needed to actually implement and export +the `data-provider` world — every plugin is currently forced to regenerate its own +bindings from a vendored copy of the WIT file, producing Rust types that are WIT-identical +but nominally distinct from this crate's own `Team`/`Fixture`/etc, defeating the point of +a shared canonical Rust API. The reference plugin (`Plugins/Bundesliga`) has hit both gaps +directly and built a placeholder `Fetcher` trait and plain-function operations as the seam +these fixes are meant to replace. + +## What Changes + +- Add a `host` WIT interface with a `fetch` function plugins import to make HTTP GET + requests, returning the response body or a structured error. +- **BREAKING**: Wire `host.fetch` into `world plugin` as an `import`, alongside the + existing `export data-provider`. Any component built against the `plugin` world now + requires a host that supplies `fetch` — bump `INTERFACE_VERSION` to `2.0` (see + design.md for why this is a major, not minor, bump). +- Reuse the existing `errors` interface's `network-failure` record for `fetch`'s failure + case rather than defining a parallel error shape. +- Make `bindings` a `pub(crate)`-visible generation point whose useful downstream surface + is re-exported: the `data-provider` interface's `Guest` trait, its `export!` macro, and + a safe Rust wrapper function around the generated `fetch` import (`host_fetch`) so a + plugin calls ordinary Rust rather than raw generated bindings. + +## Capabilities + +### New Capabilities +- `host-fetch-capability`: the WIT `host.fetch` import a plugin uses to make HTTP requests + through the host, and the Rust wrapper this crate exposes around it. + +### Modified Capabilities +- `data-provider-plugin-api`: adds a requirement that this crate expose the means for a + downstream plugin to implement and export the `data-provider` world using this crate's + own generated types (not a second, incompatible set from a vendored WIT copy), and bumps + the interface version policy's example to reflect `INTERFACE_VERSION` moving to `2.0`. + +## Impact + +- **This repo (`fulltime-plugin-api`)**: `wit/data-provider.wit` gains the `host` + interface and the `plugin` world import; `src/bindings.rs` and `src/lib.rs` change their + visibility/re-export surface; `INTERFACE_VERSION` bumps to `2.0`; `docs/plugin-authoring.md` + gains a section on implementing and exporting against the world using the new re-exports. +- **`Plugins/Bundesliga`**: once this ships, its `src/transport.rs` `Fetcher` trait and + `src/provider.rs` plain functions are replaced with an implementation against this + crate's `Guest` trait, `export!` macro, and `host_fetch` wrapper — tracked as follow-up + work in that repo, not part of this change. +- **`Apps/rust`**: its (not-yet-started) `plugin-host-runtime` change must implement the + `host.fetch` import when it builds the host runtime; this change defines the contract + that work implements against, it does not implement the host side. +- No crates.io release is required for this change to unblock `Plugins/Bundesliga` + locally (a git dependency on this branch/commit is sufficient); publishing `0.2.0` is a + separate, later action once this is reviewed and merged. diff --git a/openspec/changes/add-host-fetch-capability/specs/data-provider-plugin-api/spec.md b/openspec/changes/add-host-fetch-capability/specs/data-provider-plugin-api/spec.md new file mode 100644 index 0000000..c76640b --- /dev/null +++ b/openspec/changes/add-host-fetch-capability/specs/data-provider-plugin-api/spec.md @@ -0,0 +1,51 @@ +## ADDED Requirements + +### Requirement: Downstream Implementation Bindings +This crate SHALL expose the generated `Guest` trait and `export!` macro for the +`data-provider` interface, so a downstream plugin can implement and export the world using +this crate's own canonical types instead of regenerating an incompatible copy from a +vendored WIT file. + +#### Scenario: Plugin implements the Guest trait +- **WHEN** a plugin crate depends on this crate as an ordinary Rust library +- **THEN** it can implement this crate's re-exported `Guest` trait for `data-provider` + using this crate's own `Team`/`Fixture`/`Standings`/`Competition`/`ProviderError` types, + with no separate WIT-derived type set of its own + +#### Scenario: Plugin exports its implementation +- **WHEN** a plugin has implemented the `Guest` trait +- **THEN** it calls this crate's re-exported `export!` macro to export the implementation + as the component's `data-provider` interface, without needing its own + `wit_bindgen::generate!` invocation + +## MODIFIED Requirements + +### Requirement: Interface Versioning +The data-provider interface SHALL carry an explicit version identifier, independent of the +schema version, so the host can detect and reject plugins built against an incompatible +interface version before invoking them. + +`INTERFACE_VERSION`'s major component covers both axes of compatibility: the shape of the +`data-provider` exports a plugin implements, and the set of imports (currently, `host.fetch`) +a plugin requires from the host. A change to either axis that a plugin built against an +older major version cannot satisfy is a major bump; before `host.fetch` existed, only the +export shape was covered. + +#### Scenario: Plugin built against a newer interface than the host supports +- **WHEN** the host loads a plugin declaring an interface version newer (major) than any + version the host implements +- **THEN** the host refuses to load the plugin and reports a version-incompatibility error + +#### Scenario: Plugin built against an older, compatible interface version +- **WHEN** the host loads a plugin declaring an interface minor version lower than the + host's supported version, with the same major version +- **THEN** the host loads the plugin, since the host's interface is a superset of the + functions the plugin was built against, and the plugin requires no imports the host + cannot supply + +#### Scenario: Plugin built before the host-fetch import existed +- **WHEN** the host loads a plugin declaring `interface_version` `1.x` (built before + `host.fetch` was added to the `plugin` world) +- **THEN** the host refuses to load the plugin as a major-version mismatch against its own + `2.x` support, rather than attempting instantiation and failing at the component-linking + stage with a less informative error diff --git a/openspec/changes/add-host-fetch-capability/specs/host-fetch-capability/spec.md b/openspec/changes/add-host-fetch-capability/specs/host-fetch-capability/spec.md new file mode 100644 index 0000000..1541631 --- /dev/null +++ b/openspec/changes/add-host-fetch-capability/specs/host-fetch-capability/spec.md @@ -0,0 +1,42 @@ +## ADDED Requirements + +### Requirement: Host Fetch WIT Import +The `plugin` world SHALL import a `host` interface defining a `fetch` function, so a +plugin component cannot instantiate against a host that does not supply network access. + +#### Scenario: Host implements fetch +- **WHEN** a host loads a plugin component built against the `plugin` world +- **THEN** instantiation requires the host to supply an implementation of `host.fetch` + +#### Scenario: Plugin makes an HTTP GET request +- **WHEN** a plugin needs data from an upstream HTTP API +- **THEN** it calls `host.fetch` with the target URL and receives either the response body + or a `network-failure` error, and issues no direct network connection of its own + +### Requirement: Fetch Errors Reuse the Existing Error Shape +`host.fetch` SHALL report failures using the `errors` interface's existing +`network-failure` record rather than a separate error type. + +#### Scenario: Upstream request fails +- **WHEN** `host.fetch` cannot complete the request (network error, non-2xx status, or a + host-enforced network-host restriction from the plugin's manifest) +- **THEN** it returns `network-failure` with a message describing the failure, and the + plugin handles it identically to a `network-failure` from any other source + +### Requirement: Rust Wrapper Around the Generated Import +This crate SHALL expose a safe Rust function wrapping the generated `host.fetch` import, +so a plugin calls ordinary Rust rather than raw `wit_bindgen`-generated bindings. + +#### Scenario: Plugin calls the wrapper +- **WHEN** a plugin compiled as a `wasm32` component calls this crate's `host_fetch` + function +- **THEN** the call resolves to the generated `host.fetch` import and returns + `Result, NetworkFailure>` using this crate's own re-exported `NetworkFailure` + type + +#### Scenario: Wrapper called outside a real component instantiation +- **WHEN** `host_fetch` is referenced from code compiled for a non-`wasm32` target, or + from a `wasm32` build not instantiated by a host implementing `host.fetch` +- **THEN** the call fails to link or resolve, since the wrapper has no behavior of its own + independent of the generated import — callers are documented to gate use of it behind + `#[cfg(target_arch = "wasm32")]` and keep a separate, injectable seam for native tests diff --git a/openspec/changes/add-host-fetch-capability/tasks.md b/openspec/changes/add-host-fetch-capability/tasks.md new file mode 100644 index 0000000..9487aa8 --- /dev/null +++ b/openspec/changes/add-host-fetch-capability/tasks.md @@ -0,0 +1,40 @@ +## 1. WIT Contract + +- [x] 1.1 Add the `host` interface to `wit/data-provider.wit` with a `fetch` function + reusing `errors.network-failure` +- [x] 1.2 Add `import host;` to `world plugin`, alongside the existing `export + data-provider;` +- [x] 1.3 Bump `INTERFACE_VERSION` in `src/lib.rs` from `Version::new(1, 0)` to + `Version::new(2, 0)` + +## 2. Bindings Re-exports + +- [x] 2.1 Re-export the `export!` macro generated for the `data-provider` interface from + the crate root +- [x] 2.2 Re-export the generated `Guest` trait for `data-provider` under a clear public + name (e.g. `DataProviderGuest` or `Guest`, matching whichever reads better against the + existing `pub use bindings::fulltime::plugin_api::{errors, types}::*;` re-exports) +- [x] 2.3 Add a `host_fetch(url: &str) -> Result, NetworkFailure>` wrapper around + the generated `host.fetch` import, gated with a doc comment (not a `cfg`, since this + crate itself doesn't need to restrict compilation — only callers do) explaining it only + links inside a `wasm32` component instantiated by a compatible host + +## 3. Verification + +- [x] 3.1 `cargo build`/`test`/`clippy`/`fmt --check` all pass natively (confirms adding + the `host` import doesn't break this crate's own non-wasm CI, since nothing in this + crate's test suite calls `host_fetch`) +- [x] 3.2 Add a unit or doc test exercising `Manifest::parse` against an `interface_version + = "2.0"` manifest, confirming `INTERFACE_VERSION.accepts` behaves as documented in the + updated Interface Versioning requirement +- [x] 3.3 `cargo doc --no-deps` builds clean with the new public items documented + +## 4. Documentation + +- [x] 4.1 Update `docs/plugin-authoring.md` with a section on implementing the `Guest` + trait, calling `export!`, and using `host_fetch` (including the `#[cfg(target_arch = + "wasm32")]` + separate native-test-seam guidance from design.md) +- [x] 4.2 Update `README.md`'s "Building a plugin" pointer if the new bindings change what + it should say +- [x] 4.3 Add a `CHANGELOG.md` `[Unreleased]` entry (BREAKING: `INTERFACE_VERSION` 2.0, + `host.fetch` import required) diff --git a/src/bindings.rs b/src/bindings.rs index 609c352..0fff971 100644 --- a/src/bindings.rs +++ b/src/bindings.rs @@ -12,4 +12,6 @@ wit_bindgen::generate!({ path: "wit", generate_all, additional_derives: [serde::Serialize, serde::Deserialize, Clone, PartialEq], + pub_export_macro: true, + export_macro_name: "export", }); diff --git a/src/lib.rs b/src/lib.rs index c8b10b0..7a6a514 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -30,6 +30,26 @@ pub use version::{ParseVersionError, Version}; pub use bindings::fulltime::plugin_api::errors::*; pub use bindings::fulltime::plugin_api::types::*; +pub use bindings::export; +pub use bindings::exports; +pub use bindings::exports::fulltime::plugin_api::data_provider::Guest; + +/// Fetches the response body for an HTTP GET request to `url`, via the host's `fetch` +/// capability. +/// +/// This only links and behaves correctly when compiled as part of a `wasm32` component +/// instantiated by a host implementing the `host` interface's `fetch` function (see +/// `wit/data-provider.wit`) — it has no behavior of its own independent of that generated +/// import. Callers should gate use of it behind `#[cfg(target_arch = "wasm32")]` and keep +/// a separate, injectable seam for native unit/integration tests. +/// +/// # Errors +/// Returns [`NetworkFailure`] if the host reports the request failed (network error, +/// non-2xx status, or a plugin-manifest network-host restriction). +pub fn host_fetch(url: &str) -> Result, NetworkFailure> { + bindings::fulltime::plugin_api::host::fetch(url) +} + /// Current version of the canonical `league-data-schema` (see [`types` /// interface](https://github.com/pilgrimagesoftware/fulltime-plugin-api/blob/develop/wit/data-provider.wit)). /// @@ -43,4 +63,4 @@ pub const SCHEMA_VERSION: Version = Version::new(1, 0); /// A plugin declaring an `interface_version` in its manifest is compatible with a host /// running this version when `INTERFACE_VERSION.accepts(plugin_interface_version)` is /// `true` — see [`Version::accepts`]. -pub const INTERFACE_VERSION: Version = Version::new(1, 0); +pub const INTERFACE_VERSION: Version = Version::new(2, 0); diff --git a/src/manifest.rs b/src/manifest.rs index be342b2..e8dfaf5 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -249,4 +249,29 @@ mod tests { "#; assert!(Manifest::parse(toml).is_ok()); } + + #[test] + fn interface_version_2_0_is_accepted_by_the_current_interface_version() { + let toml = r#" + id = "bundesliga" + version = "0.1.0" + schema_version = "1.0" + interface_version = "2.0" + network_hosts = ["api.openligadb.de"] + "#; + let manifest = Manifest::parse(toml).unwrap(); + assert_eq!(manifest.interface_version, Version::new(2, 0)); + assert!(crate::INTERFACE_VERSION.accepts(manifest.interface_version)); + } + + #[test] + fn interface_version_1_0_is_rejected_after_the_host_fetch_major_bump() { + // A plugin built before `host.fetch` existed declares interface_version 1.0; the + // host's INTERFACE_VERSION is now 2.0 (major bump), so it must not accept it — see + // openspec/changes/add-host-fetch-capability/specs/data-provider-plugin-api/spec.md + // ("Plugin built before the host-fetch import existed"). + let manifest = Manifest::parse(valid_toml()).unwrap(); + assert_eq!(manifest.interface_version, Version::new(1, 0)); + assert!(!crate::INTERFACE_VERSION.accepts(manifest.interface_version)); + } } diff --git a/wit/data-provider.wit b/wit/data-provider.wit index 1a07373..f4f11b1 100644 --- a/wit/data-provider.wit +++ b/wit/data-provider.wit @@ -112,6 +112,22 @@ interface errors { } } +/// The host capability every data-provider plugin imports for upstream network access. +/// Plugins have no direct network access; every HTTP call goes through `fetch`, which the +/// host scopes to the hosts declared in the plugin's manifest `network_hosts` field. +/// +/// See `openspec/changes/add-host-fetch-capability/specs/host-fetch-capability/spec.md`. +interface host { + use errors.{network-failure}; + + /// Fetches the response body for an HTTP GET request to `url`, via the host. + /// + /// A non-2xx status or any other transport-level failure is reported as + /// `network-failure`, not a distinct variant — from the plugin's perspective both mean + /// "the request didn't succeed." + fetch: func(url: string) -> result, network-failure>; +} + /// The contract a data-provider plugin implements to supply league/competition data to /// the host. Every operation returns data typed against the canonical `types` schema; the /// interface defines no plugin-specific or provider-specific return types. @@ -139,5 +155,6 @@ interface data-provider { /// The world a data-provider plugin component implements. world plugin { + import host; export data-provider; }