Open lann opened 1 year ago
This is probably a good idea to return to now that 0.2 is done. Just sketching some thoughts based on a discussion with @lann today:
I think there are two cases:
request
or response
Case (1) is the easier case to sketch in WIT and I'm thinking looks roughly like:
package wasi:http;
interface types {
...
resource request {
...
/// request-metadata is a child resource and follows the same child rules as headers and body
metadata: func() -> request-metadata
}
resource request-metadata {
peer-IP-address: func() -> option<...>
set-peer-IP-address: func(...)
...
}
}
and similarly for response
.
For case (2), the middleware/vendor can leverage the Component Model's parametric polymorphism (and WIT's use
) to, effectively, extend request-metadata
:
package my-auth-middleware:api;
interface metadata {
use wasi:http/types.{request-metadata};
get-user: func(req: borrow<request-metadata>) -> ...
}
Thus, from, let's say JS, you could write:
import { * as auth } from 'my-auth-middleware:api/metadata';
export func handle(request) {
let user = auth.getUser(request.metadata);
...
}
If we wanted to make this syntactically nicer so that getUser
showed up as a method on the prototype chain of request.metadata
, we could implement CM/#296 (which incidentally mentions exactly this use case) which gives the bindings generator the info it needs.
For reference, a component using my-auth-middleware
could target a world looking like:
world auth-guest {
import wasi:http/types;
import my-auth-middleware:api/metadata;
import wasi:http/outgoing-handler;
export wasi:http/incoming-handler;
}
Note that outgoing-handler
/incoming-handler
(whose handle
functions take and return WASI HTTP request
s and response
s and don't know anything about my-auth-middleware
) don't have to change nor is there any dynamic cast needed anywhere. And if two components targeting my-auth-world
are linked together (linking one's exported handler to the other's imported handler, assuming host magic or 0.3 when we can collapse the two handler interfaces into wasi:http/handler
), it also just works (still without a downcasts). (Parametric polymorphism FTW!)
An alternative approach to Case (2) is to extend WASI HTTP with some totally dynamic fields/values:
package wasi:http;
interface types {
resource request-metadata {
set-dynamic: func(key: string, value: list<u8>);
get-dynamic: func(key: string) -> option<list<u8>>;
}
I expect this is what some HTTP standard libraries do and maybe there are some good use cases for it (it's better than smuggling the info through headers), so maybe we should do it too, but it seems worse from a performance and declarative-interfaces perspective.
As discussed, I really like the interface that this parametric polymorphism provides. My main concern is with the implementation of composition. For example, if I want to implement auth "middleware" by wrapping a wasi-http handler component in an auth component, I believe this design either requires host magic or it requires the auth component to implement "donut wrapping" of its parent's wasi-http interface(s)/types. The former (host magic) would probably be fine for certain "standard-ish" metadata but it isn't clear to me how it would be extensible. The latter (donut wrapping) seems plausible in principle but I'm not sure that the details of its implementation have been explored enough to be comfortable with depending on it for 0.3.
Yeah, that's a fair point, virtualization is definitely important. For this particular scenario, I think we might be able to get away with something that isn't optimal (it creates 1 more component instance than if we had full donut-wrapping producer toolchain support), but is at least is expressible in WAC/WIT today (I think?).
So the overall composed component could look like:
import types: wasi:http/types;
import outgoing: wasi:http/handler;
let auth-imports = new my-auth-middleware:import-adapter { types, outgoing };
let guest = new my:guest { auth-imports, outgoing }
let auth-exports = new my-auth-middleware:export-adapter { auth-imports, guest };
export auth-exports as wasi:http/handler;
where my-auth-middleware:import-adapter
had type:
world {
import wasi:http/types;
import wasi:http/handler;
export wasi:http/types;
export my-auth-middleware:api/metadata;
import my-auth-middleware:private-api/private-methods;
export wasi:http/handler;
}
and my-auth-middleware:export-adapter
had type:
world {
import wasi:http/types;
import my-auth-middleware:api/metadata;
import my-auth-middleware:private-api/private-methods;
import wasi:http/handler;
export wasi:http/handler;
}
and my:guest
targeted the auth-guest
world above. The private-methods
interface gives the export-adapter
component the ability to potentially call trusted exports on the import-adapter
component that you wouldn't want the guest
to be able to do (to maintain whatever auth invariants).
It's not optimal, but perhaps we could use this use case to push forward producer tooling to support donut wrapping directly so that import-adapter
and export-adapter
can be fused into one component with one memory, allowing the shared-nothing call through private-methods
to be a shared-everything call through core wasm.
WDYT?
I'll have to let others chime in on the composition implementation but the approach looks straightforward enough to me in the near term (0.3). Thanks!
Going back to https://github.com/WebAssembly/wasi-http/issues/4#issuecomment-2086877906's "case (1)", I wonder if it could/should be unified with (2) by defining "first-party" metadata in that same form as "third-party" metadata, e.g.
package wasi:http;
interface peer-metadata {
use types.{request-metadata};
peer-IP-address: func(req: borrow<request-metadata>) -> option<...>;
...
}
This would produce less-nice bindings (absent new bindgen features) for these particular functions but it would be consistent with - and establish the pattern for - "third-party" metadata. It could also dovetail nicely with optional imports to statically express whether an implementer provides the given metadata.
Edit: somewhat of an aside, peer-IP-address
is a perfect candidate for "middleware" virtualization: it is very common for a service to get the "real" peer IP from a trusted header like X-Forwarded-For
.
Ah, good point! It also replaces dynamic option<...>
result type in my original sample with more static "this is what I actually need" declaration.
@dicej pointed out that we'll run into a current WIT limitation when trying to write that private-methods
interface, since we have a single function that needs to refer to both the imported wasi:http/types.request
and the exported wasi:http/types.request
. In particular, what we need to write is:
package my-auth-middleware:private-api;
interface private-methods {
use host-http.{request as host-request};
use virtual-http.{request as virtual-request};
wrap: func(r: host-request, auth-data: ...) -> virtual-request;
}
Normally, this would fail validation because host-http
and virtual-http
are not interface names defined in this package (intentionally). What the WIT extension would allow us to do is satisfy them via with
in the world
targeted by the import adapter:
package my-auth-middleware:private-api;
world import-adapter {
import wasi:http/types;
import wasi:http/handler;
export wasi:http/types;
export my-auth-middleware:api/metadata;
export private-methods with { host-http = import wasi:http/types, virtual-http = export wasi:http/types };
export wasi:http/handler;
}
The above with { foo = import bar }
syntax was discussed as part of CM/#308 originally in the spirit of overriding the default resolution, but here there's nothing to override; if we didn't have the with
, the export private-methods;
would fail to validate.
I realized there's another approach in which import-adapter
doesn't export wasi:http/types
but instead whatever made up name it wants, using WAC to do the renaming:
package my-auth-middleware:private-api;
interface virtual-http-types {
... copy-paste wasi:http/types
}
interface private-methods {
use wasi:http/types.{request as host-request};
use virtual-http-types.{request as virtual-request};
wrap: func(r: host-request, auth-data: ...) -> virtual-request;
}
world import-adapter {
import wasi:http/types;
import wasi:http/handler;
export virtual-http-types;
export my-auth-middleware:api/metadata;
export private-methods;
export wasi:http/handler;
}
and the WAC would be updated to do the renaming from virtual-http-types
to wasi:http/types
:
import types: wasi:http/types;
import outgoing: wasi:http/handler;
let auth-imports = new my-auth-middleware:import-adapter { types, outgoing };
let guest = new my:guest { wasi:http/types = auth-imports[my-auth-middleware:private-api/virtual-http-types], outgoing }
let auth-exports = new my-auth-middleware:export-adapter { auth-imports, guest };
export auth-exports as wasi:http/handler;
which is allowed because ultimately "wasi:http/types"
is just an import name and the types match. It does feel silly to have to copy-paste wasi:http/types
into virtual-http-types
, but I think this could be avoided by (yet another) WIT feature we should add: allow include
inside interface { ... }
, so that we could write interface virtual-http-types { include wasi:http/types; }
.
So, altogether, I think we have multiple options for how to express this.
Original context: https://github.com/WebAssembly/wasi-http/pull/3#discussion_r1122091615
There are certain pieces of metadata that are commonly associated with requests but aren't part of HTTP itself, e.g.:
This kind of metadata can be inserted into requests as headers, but this has historically been a source of security and name collision issues and I hope we can offer a better alternative.
A few options to consider:
handle