6. Advanced routing

Pronto en post siguientes agregaremos a nuestro HTML un formulario para crear nuevos snippets.

Pero para hacer este trabajo de una manera mas limpia, primero tenemos que actualizar nuetras rutas para que las request que vayan hacia snippet/create sean manejadas de manera diferente dependiendo del metodo.

  • Para peticiones GET /snippet/create queremos mostrar el formulario HTML para agregar un nuevo snippet.
  • Para peticiones POST /snippet/create queremos procesar los datos del formulario y luego insertarlos a nuestra DB.

Mientras lo hacemos tenemos que mejorar otras cosas en nuestras rutas:

  • Restringiremos todas las otras rutas, las cuales simplemente regresan informacion para solo soportar peticiones GET.
  • Usaremos CLEAR URL para que las variables que pasemos por URL se vean asi /snippet/view/123 en vez de /snippet/view?id=123

Esencialmente uqremos que nuestas rutas se vean asi:

MethodPatternHandlerAction
GET/homeDisplay the home page
GET/snippet/view/:idsnippetViewDisplay a specific snippet
GET/snippet/createsnippetCreateDisplay a HTML form for creating a new snippet
POST/snippet/createsnippetCreatePostCreate a new snippet
GET/static/http.FileServerServe a specific static file

Como dije en post anteriores servemux no soporta rutas basadas en metodos o clean URLs con variables en ellas. Hay algunos trucos para lograrlo , pero la mayoria de la gente tiende a decantarse por buscar paquetes de terceros que ayuden al enrutamiento.

En esta seccion veremos:

  • Discutiremos brevemente sobre las caracteristicas de algunas librerias para el enrutamiento
  • Actualizaremos nuestros routes para usar una de las librerias.

6.1 Eligiendo un router

Hay literalmente miles de paquetes para manejar las rutas, la eleccion depende de tu perspectiva todas trabajan de manera un poco diferente, tienen diferente logicas para hacer match, diferente comportamientos y diferenes APIs.

Mensionaremos tres que son buenas para iniciar,julienschmidt/httprouter, go-chi/chi, gorilla/mux. Todos tienen buena documentacion, buena covertura en los testy funcionan bien con patrones estandar de los handlers y middlewares.

Los tres soportan method based routing y clean URLs .

En resumen.

  • julienschmidt/httprouter es el mas centrado, liviano y rapido de los tres. Automaticamente maneja las OPTIONS de las peticiones y envia respuestas 405 correctamente , ademas de que podemos customizar las repsuestas 404 y 405.

  • go-chi/chi es generalmente similiar a httprouter enterminos de caracteristicas con la principal diferencia que tambien soporta expresiones regulares y grouping en las rutas que comparten el mismo middleware. Este agrupamiento es realmente util en aplicaciones grandes donde tenes muchos middlewares que manejar. Lo malo de chi es que no maneja Automaticamente las OPTIONS de las peticiones.

  • gorilla/mux es el mas completo. Soporta expresiones regulares, te permite manejar solicitudes segun el metodo, el host y los headers. Tambien es el unico que soporta rutas personalizadas y rutas reversing La principal desventaja es que en comparacion con los otros es mas lento y consume mas memoria, aunque para una app basada en DB del tamanio de nuestra app , el impacto seria poco. Como chi tampcoo maneja las OPTIONS Automaticamente demas de que no setea un allow en los headers en las respuestas 405.

Podes ver una comparaciom mejorada aca

Como nuetra app solo necesita clean URLs y method based routing para obtener mejor performance usaremos julienschmidt/httprouter.

6.2 Clean URLs and method-based routing

Intalemos httprouter@v1

$ go get github.com/julienschmidt/httprouter@v1
go: downloading github.com/julienschmidt/httprouter v1.3.0

Antes de empezar vamos a ver como es al sintaxis:

router := httprouter.New()
router.HandlerFunc(http.MethodGet, "/snippet/view/:id", app.snippetView)

En ese ejemplo:

  • Inicializamos httprouter con la funcion New() y luego usa HandlerFunc() donde agregamos la nueva ruta que dispara el handler snippetView

  • El primer argumento de handleFunc() es el metodo HTTP que la request tiene que tener para ser considerada en el match con la peticion. Fijate que estamos usando una constante http.MethodGet en vez de un string “GET”.

  • El segundo argumento es el pattern al que la peticion tiene que pegarle.

Los patrones pueden incluir named parameters con esta forma :name, una peticion al path /snippet/view/123 o /snippet/view/foo hara match con el patron snippet/view/:id, pero una peticion a snippet/bar o _snippet/view/foo/baz, no.

Los patrones tambien pueden incluir un unico catch-all para los parametros en esta forma *name, esto hara match con todo lo que termine con el patron , como lo hace /static/*filepath.

El patron ”/” solo hara match exactamente con ”/”

Con esto en mente, Actualicemos nuestras rutas routes.go para que quede asi:

MethodPatternHandlerAction
GET/homeDisplay the home page
GET/snippet/view/:idsnippetViewDisplay a specific snippet
GET/snippet/createsnippetCreateDisplay a HTML form for creating a new snippet
POST/snippet/createsnippetCreatePostCreate a new snippet
GET/static/*filepathhttp.FileServerServe a specific static file
 package main
 import (
   "net/http"
   "github.com/julienschmidt/httprouter" // New import
   "github.com/justinas/alice"
 )

 func (app *application) routes() http.Handler {
  
-  mux := http.NewServeMux()
-	fileServer := http.FileServer(http.Dir("./ui/static/"))
-	mux.Handle("/static/", http.StripPrefix("/static", fileServer))
-	mux.HandleFunc("/", app.home)
-	mux.HandleFunc("/snippet/view", app.snippetView)
-	mux.HandleFunc("/snippet/create", app.snippetCreate)
-	// Create a middleware chain containing our 'standard' middleware
-	// which will be used for every request our application receives.
-	standard := alice.New(app.recoverPanic, app.logRequest, sercureHeaders)
-	// Return the 'standard' middleware chain followed by the servemux.
-	return standard.Then(mux)

+	// Initialize the router.
+	router := httprouter.New()
+	// Update the pattern for the route for the static files.
+	fileServer := http.FileServer(http.Dir("./ui/static/"))
+	router.Handler(http.MethodGet, "/static/*filepath", http.StripPrefix("/static", fileServer))
+	// And then create the routes using the appropriate methods, patterns and
+	// handlers.
+	router.HandlerFunc(http.MethodGet, "/", app.home)
+	router.HandlerFunc(http.MethodGet, "/snippet/view/:id", app.snippetView)
+	router.HandlerFunc(http.MethodGet, "/snippet/create", app.snippetCreate)
+	router.HandlerFunc(http.MethodPost, "/snippet/create", app.snippetCreatePost)
+	// Create the middleware chain as normal.
+	standard := alice.New(app.recoverPanic, app.logRequest, secureHeaders)
+	// Wrap the router with the middleware and return it as normal.
+	return standard.Then(router)
 }

Ahora que hicimos estos cambios vamos a hacer otros en handlers.go.

   package main
   
   import (
   	"errors"
   	"fmt"
   	"net/http"
   	"strconv"
   
   	"github.com/julienschmidt/httprouter" // New import
   	"snippetbox.alexedwards.net/internal/models"
   )
   
   func (app *application) home(w http.ResponseWriter, r *http.Request) {
   	// Because httprouter matches the "/" path exactly, we can now remove the
   	// manual check of r.URL.Path != "/" from this handler.
-  if r.URL.Path != "/" {
-		http.NotFound(w, r)
-		return
-	}
   
   	snippets, err := app.snippets.Latest()
   	if err != nil {
   		app.serverError(w, r, err)
   		return
   	}
   	data := app.newTemplateData(r)
   	data.Snippets = snippets
   	app.render(w, r, http.StatusOK, "home.html", data)
   }



   func (app *application) snippetView(w http.ResponseWriter, r *http.Request) {
+   	// When httprouter is parsing a request, the values of any named parameters
+   	// will be stored in the request context. We'll talk about request context
+   	// in detail later in the book, but for now it's enough to know that you can
+   	// use the ParamsFromContext() function to retrieve a slice containing these
+   	// parameter names and values like so:
+  	params := httprouter.ParamsFromContext(r.Context())


+   	// We can then use the ByName() method to get the value of the "id" named
+   	// parameter from the slice and validate it as normal.
+   	id, err := strconv.Atoi(params.ByName("id"))
-     id, err := strconv.Atoi(r.URL.Query().Get("id"))
   	if err != nil || id < 1 {
   		app.notFound(w)
   		return
   	}
   	snippet, err := app.snippets.Get(id)
   	if err != nil {
   		if errors.Is(err, models.ErrNoRecord) {
   			app.notFound(w)
   		} else {
   			app.serverError(w, r, err)
   		}
   		return
   	}
   	data := app.newTemplateData(r)
   	data.Snippet = snippet
   	app.render(w, r, http.StatusOK, "view.html", data)
   }
   
   // Add a new snippetCreate handler, which for now returns a placeholder
   // response. We'll update this shortly to show a HTML form.
   func (app *application) snippetCreate(w http.ResponseWriter, r *http.Request) {
   	w.Write([]byte("Display the form for creating a new snippet..."))
   }
   
   // Rename this handler to snippetCreatePost.
   func (app *application) snippetCreatePost(w http.ResponseWriter, r *http.Request) {
    // Checking if the request method is a POST is now superfluous and can be
   	// removed, because this is done automatically by httprouter.

-    if r.Method != http.MethodPost {
-	  	w.Header().Set("Allow", http.MethodPost)
-	  	app.clientError(w, http.StatusMethodNotAllowed)
-		  return
-	 }
   	title := "O snail"
   	content := "O snail\nClimb Mount Fuji,\nBut slowly, slowly!\n\n– Kobayashi Issa"
   	expires := 7
   	id, err := app.snippets.Insert(title, content, expires)
   	if err != nil {
   		app.serverError(w, r, err)
   		return
   	}
-    http.Redirect(w, r, fmt.Sprintf("/snippet/view?id=%d", id), http.StatusSeeOther)
   	// Update the redirect path to use the new clean URL format.
+   	http.Redirect(w, r, fmt.Sprintf("/snippet/view/%d", id), http.StatusSeeOther)
   }

Por ultimo cambiamos home.html para que use la nueva clear url con el estilo /snippet/view/:id.

{{define "title"}}Home{{end}} {{define "main"}}
<h2>Latest Snippets</h2>
{{if .Snippets}}
<table>
  <tr>
    <th>Title</th>
    <th>Created</th>
    <th>ID</th>
  </tr>
  {{range .Snippets}}
  <tr>
    <!-- Use the new clean URL style-->
    <td><a href="/snippet/view/{{.ID}}">{{.Title}}</a></td>
    <td>{{humanDate .Created}}</td>
    <td>#{{.ID}}</td>
  </tr>
  {{end}}
</table>
{{else}}
<p>There's nothing to see here... yet!</p>
{{end}} {{end}}

Reiniciamos el server y vamos a : http://localhost:4000/snippet/view/1

Si hacemos una peticion con el verbo HTTP incorrecto veremos el mensaje 405 Method Not Allowed.

HTTP/1.1 405 Method Not Allowed
Allow: GET, OPTIONS
Content-Security-Policy: default-src 'self'; style-src 'self' fonts.googleapis.com; font-src fonts.gstatic.com
Content-Type: text/plain; charset=utf-8
Referrer-Policy: origin-when-cross-origin
X-Content-Type-Options: nosniff
X-Frame-Options: deny
X-Xss-Protection: 0
Date: Wed, 06 Sep 2023 14:45:12 GMT
Content-Length: 19
Method Not Allowed

6.2 Custom error handlers

Antes de continuar, probemos lo siguiente:

$ curl http://localhost:4000/snippet/view/99
Not Found
$ curl http://localhost:4000/missing
404 page not found

Podemos ver que ambos devuelven 404, pero tienen un cuerpo de respuesta diferente.

Lo que esta pasando es que la primera peticion termina llamando al helper app.notFound() cuando no encuentra un snippet con el ID 99. La segunda respuesta es retornada automaticamente por httprouter cuando no puede hacer ningun match con las rutas que creamos.

Por suerte httprouter tiene una manera sensilla de personalizar este comportamiento para errores 404. Vamos a cmd/web/routes.go

package main

import (
	"net/http"

	"github.com/julienschmidt/httprouter"
	"github.com/justinas/alice" // New import
)

func (app *application) routes() http.Handler {

	router := httprouter.New()
	// Create a handler function which wraps our notFound() helper, and then
	// assign it as the custom handler for 404 Not Found responses. You can also
	// set a custom handler for 405 Method Not Allowed responses by setting
	// router.MethodNotAllowed in the same way too.
	router.NotFound = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		app.notFound(w)
	})

	// Update the pattern for the route for the static files.
	fileServer := http.FileServer(http.Dir("./ui/static/"))
	router.Handler(http.MethodGet, "/static/*filepath", http.StripPrefix("/static", fileServer))
	// And then create the routes using the appropriate methods, patterns and
	// handlers.
	router.HandlerFunc(http.MethodGet, "/", app.home)
	router.HandlerFunc(http.MethodGet, "/snippet/view/:id", app.snippetView)
	router.HandlerFunc(http.MethodGet, "/snippet/create", app.snippetCreate)
	router.HandlerFunc(http.MethodPost, "/snippet/create", app.snippetCreatePost)
	// Create the middleware chain as normal.
	standard := alice.New(app.recoverPanic, app.logRequest, sercureHeaders)
	// Wrap the router with the middleware and return it as normal.
	return standard.Then(router)
}

Si Reiniciamos el servidor y hacemos las mismas peticiones veremos el cambio.

$ curl http://localhost:4000/snippet/view/99
Not Found
$ curl http://localhost:4000/missing
Not Found

6.2 Informacion adicional

6.2 Conflicting route patterns

Es impotaste ser consiente que httprouter no soporta conflict route patterns lo que significa que no podemos tener una ruta GET /foo/new y otra que use un parametro o un comodin catch-all, como estos: GET /foo/:name o GET /foo/*name.

En muchos casos esto es positivo porque no hay reglas de prioridades en las rutas de las cuales preocuparse y que pueden llevarnos a producir bugs y comportamientos inesperados.

Pero si necesitas el soporte de ruta conflictivas, por ejemplo si queres tener una replica de los endpoints por un tema de retro compatibilidad, entonces es mejor usar chi o gorilla/mux, ambas permiten este comportamiento.

6.2 Restful routing

Si venis de Laravel o Ruby-on-Rails, te estaras preguntando porque no estructuramos nuestras rutas para que sean mas similares a una RESTFul asi :

MethodPatternHandlerAction
GET/snippetssnippetIndexDisplay a list of all snippets
GET/snippets/:idsnippetViewDisplay a specific snippet
GET/snippets/newsnippetNewDisplay a HTML form for creating a new snippet
POST/snippetssnippetCreateCreate a new snippet

Hay un par de razones.

La primera es que GET /snippets/:id y GET /snippets/new conflictuan una con la otra, por los motivos que mencionamos antes httprouter no permite estos conflictos.

La segunda es que el formulario HTML que esta en snippets/new tiene que hacer un post hacia /snippets cuando es enviado. Lo que significa que tenemos que re-renderizar el formulario para mostrar los errores y la url del usuario se cambiara a /snippets puede que lo consideres un problema , o no . Pero puede resultar sucio y confuso que esto quede asi ,especialmente si en la ruta GET /snippets renderiza una lista de snippets.

Handler naming

No hay una manera correcta o incorrecta de nombrar a nuestros handlers en Go.

En este proyecto, seguiremos la convención de postfijar los nombres de cualquier controlador que se ocupe con solicitudes POST con la palabra ‘Post’.

MethodPatternHandlerAction
GET/snippet/createsnippetCreateDisplay a HTML form for creating a new snippet
POST/snippet/createsnippetCreatePostCreate a new snippet

Alternativamente, puede publicar los nombres de cualquier controlador que muestre formularios con la palabra Form o View

MethodPatternHandlerAction
GET/snippet/createsnippetCreateFormDisplay a HTML form for creating a new snippet
POST/snippet/createsnippetCreatePostCreate a new snippet

O incluso prefijar los nombres de los controladores con las palabras show y do.

MethodPatternHandlerAction
GET/snippet/createshowSnippetCreateDisplay a HTML form for creating a new snippet
POST/snippet/createdoSnippetCreatePostCreate a new snippet