Hi There
In this blog I will show you how you can create a basic http server and make it customizable.
How to create a basic http server?
No idea but let’s see if we can ask copilot
We need first understand which interface we need to implement to create a basic http server. In Go the http server needs to implement the
http.Handler
How does it look like?
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
Could we just create a struct and implement the interface? Let’s see, we will create a pkg/server.go file and pkg/server_test.go so we can test it.
func Test_NewServer(t *testing.T) {
t.Run("should return new server", func(t *testing.T) {
httpserver := server.NewServer()
httptestserver := httptest.NewServer(httpserver)
})
}
In go, we use httptest package to test http servers, so we can use the httptest.NewServer to create a new server, and we need to pass a http.Handler, so we can pass our server, but this will give us an error
cannot use httpserver (variable of type *server.HTTPServer) as http.Handler value in argument to httptest.NewServer: *server.HTTPServer does not implement http.Handler (missing method ServeHTTP)
let’s implement the ServeHTTP method, we can use this trick to implement the interface with one click in vscode or vim with code action.
var _ http.Handler = (*HTTPServer)(nil)
once you click on the code action, it will create the method for you.
func (s *HTTPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
panic("not implemented") // TODO: Implement
}
Let’s just return the classic Hello World
for now.
// ServeHTTP implements http.Handler.
func (*HTTPServer) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
rw.Header().Set("Content-Type", "text/plain")
rw.WriteHeader(http.StatusOK)
rw.Write([]byte("Hello, World!"))
}
There are many ways we can tests http.Handler in golang, one of them is using the httptest package, we can use the httptest.NewRecorder
to record the response and then we can check the response.
func Test_ServeHTTP(t *testing.T) {
t.Run("should return response", func(t *testing.T) {
httpserver := server.NewServer()
httptestserver := httptest.NewServer(httpserver)
defer httptestserver.Close()
require.NotNil(t, httpserver)
require.NotNil(t, httptestserver)
req := httptest.NewRequest("GET", httptestserver.URL, nil)
rr := httptest.NewRecorder()
httpserver.ServeHTTP(rr, req)
require.Equal(t, 200, rr.Code)
require.Equal(t, "Hello, World!", rr.Body.String())
})
}
The other way is to use default client from net/http package, we can use the client to make a request to the server and check the response.
func Test_ServeHTTP_UsingClient(t *testing.T) {
t.Run("should return response", func(t *testing.T) {
httpserver := server.NewServer()
httptestserver := httptest.NewServer(httpserver)
defer httptestserver.Close()
require.NotNil(t, httpserver)
require.NotNil(t, httptestserver)
req, err := http.NewRequest("GET", httptestserver.URL, nil)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.NoError(t, err)
assert.NotNil(t, resp)
assert.Equal(t, 200, resp.StatusCode)
assert.Equal(t, "Hello, World!", string(body))
})
}
But how does httptest.NewServer()
works under the hood? the httptest.NewServer will create a new server and it will start it.
// NewServer starts and returns a new Server.
// The caller should call Close when finished, to shut it down.
func NewServer(handler http.Handler) *Server {
ts := NewUnstartedServer(handler)
ts.Start()
return ts
}
As we can see, it will create a new server and start it, this is cool! Later we are sending a request to the test server, we know the url because the httptest.NewServer will return the url for us.
func Test_ServeHTTP(t *testing.T) {
t.Run("should return new server", func(t *testing.T) {
...
req := httptest.NewRequest("GET", httptestserver.URL, nil)
...
})
}
But we don’t have write our own server, we can use the default server from net/http package
, see the example:
package httpserver
import (
"net/http"
)
type HTTPServer struct {
server *http.Server
}
type HTTPServerOption func(*HTTPServer)
func NewHTTPServer(opts ...HTTPServerOption) *HTTPServer {
server := newHTTPServer(opts...)
return server
}
// ServeHTTP implements http.Handler.
func (s *HTTPServer) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
s.server.Handler.ServeHTTP(rw, r)
}
func WithDefaultServerOptions() HTTPServerOption {
return func(s *HTTPServer) {
s.server = &http.Server{
Addr: ":8080",
Handler: http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
rw.Header().Set("Content-Type", "text/plain")
rw.WriteHeader(http.StatusOK)
rw.Write([]byte("Hello, World!"))
}),
}
}
}
func newHTTPServer(opts ...HTTPServerOption) *HTTPServer {
server := &HTTPServer{}
for _, opt := range opts {
opt(server)
}
return server
}
We can do something like this above and write our custom http server, we just need to compose our struct
with the default http server, this is called struct embedding, we can embed the default http server in our struct and
then we can use the default http server methods.
Don’t be afraid of the WithDefaultServerOptions
, it’s just a function that returns a function, this is called functional options pattern.
We can pass this function to our constructor and then we can use it to set the default options for our server.
Let’s run this server and send a request to it.
package main
import (
httpserver "httpserver/pkg"
)
func main() {
server := httpserver.NewHTTPServer(httpserver.WithDefaultServerOptions())
server.Start()
}
❯ http GET http://localhost:8080
HTTP/1.1 200 OK
Content-Length: 13
Content-Type: text/plain
Date: Sun, 15 Oct 2023 18:24:02 GMT
Hello, World!
Ok, works fine, but what if we want to have a health endpoint? We can create a new endpoint and add it to our server. Let’s add router to our server, we can use the gorilla/mux or chi. I will use chi.
package httpserver
import (
"net/http"
"github.com/go-chi/chi/v5"
)
func NewRouter() http.Handler {
r := chi.NewRouter()
r.Get("/", func(rw http.ResponseWriter, r *http.Request) {
rw.Header().Set("Content-Type", "text/plain")
rw.WriteHeader(http.StatusOK)
rw.Write([]byte("Hello, World!"))
})
r.Get("/health", func(rw http.ResponseWriter, r *http.Request) {
rw.Header().Set("Content-Type", "text/plain")
rw.WriteHeader(http.StatusOK)
rw.Write([]byte("OK"))
})
return r
}
There is one thing we need to change, we need to somehow pass the router to our server, we can use the functional options pattern again.
func main() {
server := httpserver.NewHTTPServer(
httpserver.WithDefaultServerOptions(),
httpserver.WithRouter(httpserver.NewRouter()))
server.Start()
}
Everything works but we don’t have tests for our router, we can use the httptest
which we already know.
func TestNewRouter(t *testing.T) {
r := httpserver.NewRouter()
t.Run("should respond with 'Hello, World!' for /", func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
require.Equal(t, "text/plain", rr.Header().Get("Content-Type"))
require.Equal(t, "Hello, World!", rr.Body.String())
})
t.Run("should respond with 'OK' for /health", func(t *testing.T) {
req := httptest.NewRequest("GET", "/health", nil)
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
require.Equal(t, "text/plain", rr.Header().Get("Content-Type"))
require.Equal(t, "OK", rr.Body.String())
})
}
Happy hacking!