Living on the Edge: Rama

I've started a music blog: Crusty Metallion

It's built with Rama, which is a modular service framework that helps you build web things, such as proxies, web servers, and HTTP clients.

In typical fashion for me, I run before I walk. With the barest reading of their docs and examples, I figured out and put together a sort of template for building out a server rendered web application. The code for this experiment is here: hello-rama-web. It will evolve over time and has given me a good starting point for building.

I did some work today on the music blog, the main goal was to add cache control for the static web assets. As it stood, assets such as CSS, JavaScript, and images were served on every request. This isn't the greatest and so wanted to implement something rudimentary to at least get started.

The question was...how.

I poked around in the docs but couldn't really find anything. I asked Claude AI, who pushed me to just ask on the Rama Discord, which I did. AFter posting an issue, I had suggestions from the team back almost immediately. However, there was still some amount of sweating and bulging of forehead happening.

How...how indeed.

The Service Trait

I did my usual thing, slinging stuff at my startup.rs function, where the Router type gets built, until something stuck. Nothing stuck. I left for awhile and took a break, reading what was in the Discord and trying to reflect on it. Nothing still.

I decided to take a step back and look again at the docs.

Almost the entirety of Rama is founded in the notion of a Service trait, which looks like this:

pub trait Service<Input>:
    Sized
    + Send
    + Sync
    + 'static {
    type Output: Send + 'static;
    type Error: Send + 'static;

    // Required method
    fn serve(
        &self,
        input: Input,
    ) -> impl Future<Output = Result<Self::Output, Self::Error>> + Send;

    // Provided method
    fn boxed(self) -> BoxService<Input, Self::Output, Self::Error> { ... }
}

I read the explanation here: Services all the way down and even the recommended background here: Tower Service, after which I took a step back and thought about what I was doing.

Static assets can be served in Rama starting like this:

// create the directory for static assets to be served from
let assets_dir = ServeDir::new("static").with_directory_serve_mode(NotFound);

This gives you back a ServeDir type, modified to return "not found" if the asset isn't available.

With that, I knew that I had to do this, to modify the cache control headers:

// add cache control policy to static assets
let cached_assets = SetResponseHeaderLayer::if_not_present_typed(
  CacheControl::new()
    .with_max_age_seconds(604800)
    .with_public(),
)
  .into_layer(assets_dir);

This takes the assets_dir you just created, and adds appropriate cache-control headers to the response that comes when the asset is served. The key thing I missed until studying the docs was the .into_layer() bit. This is a method on the Layer trait which helps you turn something into a Service.

This was when the light bulb went on.

Everything in Rama is a service...

I already had my router built, and I knew that the Router type in Rama implements the Service trait. It was already a thing that takes a request and produces a response (or error).

Router::new_with_state(state)
  .with_sub_router_make_fn("/api", |router| {
    router.with_sub_router_make_fn("/v1", |router| {
      router.with_get("/health_check", health_check)
      })
    })
    .with_get("/", home_page)
    .with_get("/concerts", concerts_page)
    .with_get("/photos", photos_page)
    .with_get("/about", about_page)
    .with_get("/posts", list_posts)
    .with_get("/posts/{slug}", show_post)
    .with_get("/static/datastar.js", DatastarScript::default())
    .with_get("/robots.txt", robots_txt)
    .with_get("/sitemap.xml", sitemap_xml)
    .with_sub_service("/static", cached_assets)
    .with_not_found(not_found)

The missing link was to take my cached_assets service and add it to the router, which is where the .with_sub_service comes in. That method takes a path and a service. Bingo.

After that, Everything I had already just worked.

pub async fn run(self, configuration: &Settings) -> Result<(), BoxError> {
  let graceful = Shutdown::default();

  let router = self.router;
  let listener = self.listener;

  let http_service_with_tracing =
    TraceLayer::new_for_http().make_span_with(make_request_span);
  tracing::info!("Running the application...");
  graceful.spawn_task_fn(async |guard| {
    let exec = Executor::graceful(guard.clone());
    let http_service =
      HttpServer::auto(exec).service(http_service_with_tracing.into_layer(router));
    listener.serve_graceful(guard, http_service).await;
    });

  graceful
    .shutdown_with_limit(Duration::from_secs(
      configuration.application.shutdown_timeout,
    ))
    .await?;

  Ok(())
}

I'm leaving out some detail here but basically I have an Application type with build and run methods that are called from main.rs. I seem to know only one pattern, the Zero to Production in Rust pattern, and I figured out (with help from the Rama crew) how to adapt it in the "Rama" way.

So, to wrap up, remember this about Rama.

Everything (mostly) is a service.