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).

Headers

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.