Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ See [`Version::accepts`].

```toml
[dependencies]
fulltime-plugin-api = "0.1"
fulltime-plugin-api = "0.2"
```

```rust
Expand Down
69 changes: 63 additions & 6 deletions docs/plugin-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8> = 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`]:
Expand All @@ -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"]
```

Expand Down Expand Up @@ -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<Vec<fulltime_plugin_api::Competition>, 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
2 changes: 2 additions & 0 deletions openspec/changes/add-host-fetch-capability/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-20
159 changes: 159 additions & 0 deletions openspec/changes/add-host-fetch-capability/design.md
Original file line number Diff line number Diff line change
@@ -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<u8>` 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<list<u8>, 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 <path>` 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<Vec<u8>, 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.
58 changes: 58 additions & 0 deletions openspec/changes/add-host-fetch-capability/proposal.md
Original file line number Diff line number Diff line change
@@ -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.
Loading