Responses
After a successful checkout, the seat picker needs to know where to send the customer next. Partners want the line-up as JSON, fans want to see posters, and the staff want every order of a concert in a spreadsheet.
JSON
Response::json turns anything serializable into JSON. The checkout now answers with a link to the new order:
Reservation::Reserved { order_id } => Response::json(
201,
&json!({
"order_url": addressed(routes.public.show_order(order_id.to_string()))?.url()
}),
),
The order page
An order page shows the concert, the venue, and every seat. Declare that an order has many tickets with #[has_many], naming the key that points back at the order:
#[model(table = "orders")]
#[index(name = "orders_by_customer", fields = [customer, id])]
#[index(name = "orders_by_concert", fields = [concert, id])]
#[has_many(name = "tickets", model = Ticket, key = order)]
#[derive(Clone)]
pub struct Order {
The tickets_by_order index from the previous chapter lists an order's tickets in a stable order, so Margaret can load them in one go. A has_many relation without such an index is a build error:
Error: Model { source: HasManyRequiresOrderedIndex { key: "order", model: "crate::orders::order::Order", relation: "tickets" } }
Shapes nest, so the order shape reuses ConcertWithVenue. A has_many relation always has a limit, and arrives as Children, which also tells you whether the list was Complete or Truncated:
use margaret::framework::active_record::children::Children;
use margaret::framework::macros::eager_load;
use crate::concerts::concert_with_venue::ConcertWithVenue;
use crate::orders::order::Order;
use crate::orders::ticket::Ticket;
#[eager_load(model = Order)]
pub struct OrderDetails {
#[base]
pub order: Order,
#[relation(concert)]
pub concert: ConcertWithVenue,
#[relation(tickets, limit = 8)]
pub tickets: Children<Ticket>,
}
The route binds the whole shape by the order's primary key:
#[process]
pub fn respond(
&self,
#[route_parameter(from = "order")] OrderDetails {
order,
concert: ConcertWithVenue { concert, venue },
tickets,
}: OrderDetails,
) -> anyhow::Result<Response> {
let seats: Vec<String> = tickets
.records
.iter()
.map(|ticket| ticket.seat.to_string())
.collect();
Ok(Response::text(
200,
format!(
"{} at {}, {}. Seats {} ({}).",
concert.artist,
venue.name,
concert.starts_at.format("%b %e"),
seats.join(", "),
order.status.label()
),
))
}
$ curl http://127.0.0.1:8080/orders/01a1325c-e1a6-72ee-a27d-d34a25f114da
Paper Lanterns at Riverside Hall, Nov 14. Seats 12, 13 (reserved).
Partners poll the line-up, so let them cache it for a minute. Models hold keys, which aren't meant for the outside world, so the API answers with a ConcertSummary built from each ConcertWithVenue:
#[derive(Serialize)]
pub struct ConcertSummary {
pub artist: String,
pub city: String,
pub price: Decimal,
pub starts_at: DateTime<Utc>,
pub venue: String,
}
#[process]
pub async fn respond(&self) -> anyhow::Result<Response> {
let concerts: Vec<ConcertSummary> = self
.concerts
.upcoming()
.await?
.into_iter()
.map(ConcertSummary::from)
.collect();
Ok(Response::json(200, &concerts).header("cache-control", "public, max-age=60"))
}
Bytes
Concert pages need their posters. A concert has at most one poster, so declare it with #[has_one]:
#[model(table = "concerts")]
#[index(name = "concerts_by_start", fields = [starts_at, id])]
#[index(name = "concerts_by_artist", fields = [artist, starts_at, id])]
#[has_one(name = "poster", model = Poster, key = concert)]
#[derive(Clone)]
pub struct Concert {
A has_one relation arrives as an Option, and Response::bytes sends the image with its content type:
#[eager_load(model = Concert)]
pub struct ConcertWithPoster {
#[base]
pub concert: Concert,
#[relation(poster)]
pub poster: Option<Poster>,
}
#[singleton]
#[responds_to_http(method = RouteMethod::Get, path = "/concerts/{concert}/poster", server = "public")]
pub struct ShowPoster;
impl ShowPoster {
#[process]
pub fn respond(
&self,
#[route_parameter(from = "concert")] ConcertWithPoster { poster, .. }: ConcertWithPoster,
) -> anyhow::Result<Response> {
Ok(match poster {
Some(Poster {
content_type,
image,
..
}) => Response::bytes(200, content_type, image),
None => Response::not_found(),
})
}
}
Streaming responses
A sold-out stadium concert has tens of thousands of orders, and the staff want them all as CSV. Instead of building the whole file in memory, Response::stream sends it page by page. Each page loads 500 orders together with their customers, and resumes where the previous one ended:
#[process]
pub fn respond(
&self,
#[route_parameter(from = "concert")] concert: Concert,
) -> anyhow::Result<Response> {
let database = self.database.clone();
let first_page = Order::query()
.concert
.eq(Key::of(&concert))
.id
.ascending()
.limit::<500>();
let rows = stream::try_unfold(Some(first_page), move |page| {
let database = database.clone();
async move {
let Some(page) = page else {
return Ok::<_, ActiveRecordError>(None);
};
let Page { next, records } = page
.clone()
.load::<OrderWithCustomer, _>(database.as_ref())
.await?;
let csv: String = records
.into_iter()
.map(|OrderWithCustomer { order, customer }| {
format!("{},{},{}\n", order.id, customer.email, order.status.label())
})
.collect();
let next_page = match next {
NextPage::Continues(cursor) => Some(page.resume(cursor)),
NextPage::Exhausted => None,
};
Ok(Some((Bytes::from(csv), next_page)))
}
});
Ok(Response::stream(200, "text/csv; charset=utf-8", rows)
.header("content-disposition", "attachment; filename=\"orders.csv\""))
}
The stream yields Bytes chunks, so add bytes and futures-util to your dependencies. OrderWithCustomer is one more shape: an order with its customer.
Other responses
Response also has text and html, plus shortcuts like not_found(), unauthorized(), and forbidden(). Routes can also return a redirect or a forward.
Errors
When a route returns an error, like a lost database connection, Margaret logs it and answers 500 Internal Server Error. The details stay in your logs.