tower-rs / tower-http

HTTP specific Tower utilities.
702 stars 165 forks source link

Allow ServeDir to rewrite path to include .html #383

Open jdukewich opened 1 year ago

jdukewich commented 1 year ago

Feature Request

Motivation

Next.js Static Export results in a /out directory that looks something like

/out/index.html
/out/register.html
/out/404.html

This differs slightly from a traditional SPA where all routes would go to index.html, as a request to /register should use the register.html file.

There may be a way to accomplish what I'm asking, but I couldn't figure it out from the docs and/or examples. I tried use ServeDir as so

let app = Router::new().nest_service("/", ServeDir::new("out"));

Going to / correctly returns the index page. Going to /register.html correctly returns the register page. But I want /register to also return the register page, but it returns 404

Proposal

For a given path without any extension, attempt to append .html. Similar to append_index_html_on_directories, but could have something such as append_html_on_directories where it would only append ".html" rather than "index.html" to the path.

Alternatives

Not sure of any.

Pistonight commented 1 year ago

I came up with a workaround that uses a custom service that wraps ServeDir as the fallback. The custom service will always add .html to the request path

use std::convert::Infallible;
use std::task::{Context, Poll};

use axum::http::{Request, Response};
use axum::body::Bytes;
use http_body::Body;
use tower::Service;
use tower_http::services::ServeDir;

/// Service that automatically adding .html extension to requests
#[derive(Debug, Clone)]
pub struct AddHtmlExtService<Fallback>(pub ServeDir<Fallback>);

impl<ReqBody, F, FResBody> Service<Request<ReqBody>> for AddHtmlExtService<F>
where
    F: Service<Request<ReqBody>, Response = Response<FResBody>, Error = Infallible> + Clone,
    F::Future: Send + 'static,
    FResBody: Body<Data = Bytes> + Send + 'static,
    FResBody::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
{
    type Response = <ServeDir<F> as Service<Request<ReqBody>>>::Response;
    type Error = <ServeDir<F> as Service<Request<ReqBody>>>::Error;
    type Future = <ServeDir<F> as Service<Request<ReqBody>>>::Future;

    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        <ServeDir<F> as Service<Request<ReqBody>>>::poll_ready(&mut self.0, cx)
    }

    fn call(&mut self, mut req: Request<ReqBody>) -> Self::Future {
        // this removes the scheme and authority, but it's ok since ServeDir doesn't care
        if let Ok(uri) = format!("{}.html", req.uri().path()).parse() {
            *req.uri_mut() = uri;
        }
        self.0.call(req)
    }
}

To compose the service:

let service = ServeDir::new("foo").fallback(AddHtmlExtService(ServeDir::new("foo")))

When this service encounters /register, it cannot find the file, so it will call the fallback. The fallback will call the inner service with /register.html which succeeds.

I don't know if this works for all cases, but from some simple testing it seems to work. Also this can probably be a bit more efficient if it is built in to ServeDir