Tydzień odpoczynku starczy. Można wrócić do pisania 🙂 Dziś zaimplementujemy własny interceptor w frameworku Wasabi.

Czym jest Wasabi?

Wasabi to framework HTTP napisany w Kotlinie. Pozwala na tworzenie aplikacji wykorzystujących protokół http jako warstwy komunikacji. Posiada wiele elementów, które pozwalają go kwalifikować jako narzędzie REST, ale nie jest na pewno frameworkiem REST. Można tworzyć rozwiązania zorientowane na zasoby, ale nie to jest głównym zadaniem.

Cały mechanizm działania opiera się o dobrze znane elementy takie jak routing, kanały (channels), metody HTTP. Całość jest postawiona na nettym.

Czym są interceptory?

Jak sama nazwa wskazuje, służą one do przechwytywania żądań, które przychodzą do serwera. Następnie wykonywana jest pewna logika związana z żądaniem i wynik jest zwracany jako odpowiedź serwera, następuje przerwanie przetwarzania, albo przesyłany do dalszej obróbki. Można o nich myśleć jak o filtrach znanych z aplikacji webowych. Choć są trochę bogatsze, jeśli chodzi o możliwości.

Jednym z przypadków użycia interceptora jest obsługa statycznych zasobów takich jak statyczne, z punktu widzenia serwera, strony html, pliki js, css, obrazki. Innym jest obsługa nagłówków, autoryzacji, negocjacja dodatkowych parametrów połączenia itp.

Nasz mały interceptor

Wasabi ma na pokładzie klasę StaticFileInterceptor, która pozwala na obsługę statycznych zasobów. Ma on jednak małą wadę w postaci nie możności obsługi domyślnego pliku dla katalogu. Inaczej mówiąc, jeżeli żądanie wskazuje na zasób, który jest katalogiem to dostajemy 404, o ile gdzieś dalej nie leży mapowanie pasujące do wskazanego zasobu, a nie tak jak w przypadku serwerów www plik index.XXX.

Zgłosiłem odpowiednią poprawkę, która została już wdrożona, ale chciałbym omówić, jak do niej doszedłem.

Najpierw kod oryginalnego interceptora:

Listing 1. StaticFileInterceptor stan wyjściowy

public class StaticFileInterceptor(val folder: String): Interceptor() {
    override fun intercept(request: Request, response: Response): Boolean {
        var executeNext = false
        if (request.method == HttpMethod.GET) {
            val fullPath = "${folder}${request.uri}"
            val file = File(fullPath)
            if (file.exists() && file.isFile()) {
                response.setFileResponseHeaders(fullPath)
            } else {
                executeNext = true
            }
        } else {
            executeNext = true
        }
        return executeNext
    }
}

public fun AppServer.serveStaticFilesFromFolder(folder: String) {
    val staticInterceptor = StaticFileInterceptor(folder)
    intercept(staticInterceptor)
}

Mamy tu do czynienia z dwoma elementami. Pierwszy z nich, to implementacja interfejsu Interceptor. Metoda intercept kieruje dalszym przetwarzaniem żądania w następujący sposób. Jeżeli zwróci true, to żądanie jest przekazywane do mechanizmu routingu. Jeżeli zwróci false, to żądanie jest kończone i odsyłane, tak jak jest, co oznacza, że obiekt Response jest przekazywany do mechanizmu zamieniającego go na odpowiedni obiekt z nettiego, który dalej już robi robotę sieciową. Drugim elementem jest funkcja rozszerzająca, która jest dodana do AppServer i obudowuje mechanizm tworzenie i dodawania interceptora.

Wadę tego rozwiązania już opisałem wyżej. Przejdźmy więc do naszej implementacji, która jest trochę inna niż ta, która poszła do repozytorium, ponieważ znacznie intensywniej wykorzystuje wyrażenie when, które nie wszyscy lubią.

Listing 2. Nasz interceptor

class CustomStaticFileInterceptor(val folder: String, val useDefaultFile: Boolean = false, val defaultFile: String = "index.html") : Interceptor() {

    private fun existingDir(path: String): Boolean {
        val file = File(path)
        return file.exists() && file.isDirectory
    }

    private fun existingFile(path: String): Boolean {
        val file = File(path)
        return file.exists() && file.isFile
    }

    override fun intercept(request: Request, response: Response): Boolean {
        return when (request.method) {
            HttpMethod.GET -> {
                val fullPath = "${folder}${request.uri}"
                when {
                    existingFile(fullPath) -> {
                        response.setFileResponseHeaders(fullPath); false
                    }
                    existingDir(fullPath) && useDefaultFile -> {
                        response.setFileResponseHeaders("${fullPath}/${defaultFile}"); false
                    }
                    else -> true
                }
            }
            else -> true
        }
    }
}

fun AppServer.serveStaticFiles(folder: String, useDefaultFile: Boolean = false, defaultFile: String = "index.html") {
    val staticInterceptor = CustomStaticFileInterceptor(folder, useDefaultFile, defaultFile)
    this.intercept(staticInterceptor)
}

W tej wersji mamy kilka drobnych zmian. Po pierwsze dodałem dwa pola useDefaultFile oraz defaultFile, które mają domyślne wartości. Pozwoli nam to zachować wsteczną kompatybilność API. Następnie dopasowujemy warunki działania interceptora. Jeżeli mamy do czynienia z żądaniem innym niż GET, to zwracamy true, co pozwala na kontynuowanie przetwarzania (ostatni else). Jeżeli jednak mamy do czynienia z żądaniem GET, to mamy trzy możliwości. Pierwsza żądanie dotyczy istniejącego pliku, wtedy budujemy odpowiedź i zwracamy false. Druga to sytuacja, gdy żądanie dotyczy katalogu i mamy włączoną obsługę plików domyślnych. Wtedy budujemy odpowiedź z domyślnym plikiem, bez sprawdzania, czy plik istnieje – jego brak to błąd konfiguracji i nas nie obchodzi, i zwracamy false. Trzecia sytuacja reprezentowana przez else, to żądanie katalogu przy wyłączonej obsłudze plików. Zwracamy true i tyle.

W celu zachowania spójności musimy też dodać funkcję do AppServer. W zgłoszonej poprawce zmieniamy istniejącą klasę, a zatem, zamiast dodawać funkcję, zmieniamy ją. Ostatnim krokiem jest likwidacja powtórzeń na poziomie wartości domyślnych. Wystarczy je wyekstrahować do prywatnych stałych w pliku:

Listing 3. Ekstrakcja stałych i nagłówki

private val DEFAULT_USE_DEFAULT_FILE = false
private val DEFAULT_DEFAULT_FILE = "index.html"

class CustomStaticFileInterceptor(val folder: String, val useDefaultFile: Boolean = DEFAULT_USE_DEFAULT_FILE, val defaultFile: String = DEFAULT_DEFAULT_FILE) : Interceptor()
fun AppServer.serveStaticFiles(folder: String, useDefaultFile: Boolean = DEFAULT_USE_DEFAULT_FILE, defaultFile: String = DEFAULT_DEFAULT_FILE)

Podsumowanie

Wasabi framework jest bardzo młodym rozwiązaniem, ale świetnie nadaje się jako miejsce gdzie połączyć naukę języka z rozwojem rzeczywistego oprogramowania. Dzisiejszy przykład pokazuje jak łatwo w kotlinie jest stworzyć rozszerzenie do istniejącego rozwiązania, ponieważ język wymusza na twórcach elastyczność w tworzeniu kodu.