HTTP Surface Collections
Previously, we've talked about how to embrace "parse, don't validate" in your Rust code by encoding runtime-checked invariants into your types. Let's expand on that a bit by applying it to collections. We'll also talk a bit on performance here.
It's common in Rust web frameworks to support parsing a multi-element parameter of a URL or request body into a Vec<T>.
Let's say you want to take in a comma-separated list of u64 as a chain_ids query parameter, like in this GET request:
/supported?chain_ids=1,10,8543
we can implement that surface in poem out of the box, like so:
#[oai(path = "/supported", method = "get")]
async fn chains_supported(
&self,
#[oai(explode = false)]
Query(chain_ids): Query<Vec<u64>>,
) -> Result<Json<Vec<bool>>> {
unimplemented!()
}
This is useful & convenient, but it's less than ideal in a few ways. Let's talk about some improvements we can make in both route specification & performance.
Element Count Limits
We probably really don't want to allow an unlimited number of elements for chain_ids. How can we limit that?
Validation
Your HTTP framework may offer an easy way to add a check that occurs before your route handler code to check common conditions, like element count. Let's say we want between 1 and 50 elements. In poem, you can do this like so:
#[oai(path = "/supported", method = "get")]
async fn chains_supported(
&self,
#[oai(validator(min_items = 1, max_items = 50), explode = false)]
Query(chain_ids): Query<Vec<u64>>,
) -> Result<Json<Vec<bool>>> {
unimplemented!()
}
This isn't really ideal, though. Previous readers may note that this is validation, and we want to parse instead.
How could we encode the minimum and maximum element count for this parameter into a type? Maybe we could use some sort of fixed-capacity vector?
Type Encoding
So we want a vector type with a fixed max capacity that we can specify our chain_ids parameter as. How about an ArrayVec (LINKME)? That's a very popular crate that fits the bill, let's try it.
#[oai(path = "/supported", method = "get")]
async fn chains_supported(
&self,
#[oai(explode = false)]
Query(chain_ids): Query<ArrayVec<u64, 50>>,
) -> Result<Json<Vec<bool>>> {
unimplemented!()
}
this actually doesn't compile, because poem hasn't included support for parsing an ArrayVec like a Vec.
What if we wanted to use an ArrayVec or other linear collection type here anyway?
Newtypes
As usual, there's a way to make it work nicely with newtype wrappers.
TODO EXPLAIN HERE
NOT DONE
Poem Glue
impl<T: Type, const SIZE: usize> Type for PoemArrayVec<T, SIZE> {
const IS_REQUIRED: bool = true;
type RawValueType = Self;
type RawElementValueType = T;
fn name() -> Cow<'static, str> {
<[T; SIZE]>::name()
}
fn schema_ref() -> MetaSchemaRef {
fixed_capacity_schema_ref::<T, SIZE>()
}
fn as_raw_value(&self) -> Option<&Self::RawValueType> {
Some(self)
}
fn raw_element_iter<'a>(
&'a self,
) -> Box<dyn Iterator<Item = &'a Self::RawElementValueType> + 'a> {
Box::new(self.0.iter())
}
}
Dereferencing To [T]
You should have any Vec<T>-like collection you're using in the top level function for a route dereference to a slice [T]. This allows you to easily switch out the type of the collection at the http layer without affecting your logic that comes after parsing.
poem-stackvec implements this, for example:
use arrayvec::ArrayVec;
pub struct PoemArrayVec<T, const SIZE: usize>(pub ArrayVec<T, SIZE>);
impl<T, const SIZE: usize> Deref for PoemArrayVec<T, SIZE> {
type Target = [T];
fn deref(&self) -> &Self::Target {
self.0.as_slice()
}
}
Take Slices in Inner Handlers
My preferred pattern for implementing HTTP handlers in general is:
- Have the function that declares the HTTP route be a very thin wrapper around an inner handler function
- That inner handler should prefer taking slices
&[T]instead ofVec<T>,ArrayVec<T, LEN>, or similar.
#[oai(path = "/supported", method = "get")]
async fn ids_supported(
&self,
#[oai(explode = false)]
Query(ids): Query<PoemArrayVec<u64, 50>>,
) -> Result<Json<Vec<bool>>> {
// deref here to keep inner logic from caring about the collection type
inner_handler(&ids)
.await
.map(Json)
}
// takes a slice, so is indifferent to the route's collection type
async fn inner_handler(ids: &[u64]) -> Result<Vec<bool>> {
Ok(ids.into_iter().map(|id| id <= 8543).collect())
}
This way, you don't have to propagate any special collection types further into your codebase. This is a nice way to strictly enforce input parsing, without making all the interior code deal with non-standard collections. Feels especially productive if you already have a bunch of your inner handling code written by the time you're improving the parsing at the route surface.
This isn't always the right choice - after all, you may be discarding some type information that's useful later! To me, it feels like a productive pattern, and what we gain in iteration speed & decoupling from the HTTP layer is worth it.