Skip to content

Commit

Permalink
feat: add /sse and /ws endpoints (#1)
Browse files Browse the repository at this point in the history
  • Loading branch information
acouvreur authored Nov 9, 2024
1 parent 39abad4 commit 0794346
Show file tree
Hide file tree
Showing 9 changed files with 321 additions and 12 deletions.
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

Mimic is a configurable web-server with a configurable behavior.

<!-- TOC -->
* [Mimic](#mimic)
* [Usage](#usage)
* [Endpoints](#endpoints)
* [`/`](#)
* [`/health`](#health)
* [`/sse`](#sse)
* [`/ws`](#ws)
* [Configuration](#configuration)
<!-- TOC -->

## Usage

```bash
Expand Down Expand Up @@ -31,6 +42,45 @@ mimic
Mimic says hello!
```

## Endpoints

### `/`

Simple endpoint that says `Mimic says hello!`

### `/health`

Endpoint that returns the current health of the application based on the configuration.

### `/sse`

A SSE (Server Sent Event) endpoint to subscribe to that returns the current date every second.

```bash
GET http://localhost:80/sse

HTTP/1.1 200 OK
Cache-Control: no-cache
Connection: keep-alive
Content-Type: text/event-stream
Date: Sat, 09 Nov 2024 18:14:37 GMT
Transfer-Encoding: chunked

Response code: 200 (OK); Time: 1004ms (1 s 4 ms)

data: Current time: 2024-11-09T13:14:37-05:00

data: Current time: 2024-11-09T13:14:38-05:00

data: Current time: 2024-11-09T13:14:39-05:00

data: Current time: 2024-11-09T13:14:40-05:00
```

### `/ws`

A WebSocket endpoint to subscribe to that greets you and then repeats what you send.

## Configuration

```bash
Expand Down
2 changes: 1 addition & 1 deletion buildx.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ RUN apk --no-cache --no-progress add git tzdata make \
FROM scratch

COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
COPY mimic /
COPY mimic .

ENTRYPOINT ["/mimic"]
EXPOSE 80
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
module github.com/sablierapp/mimic

go 1.23.2

require (
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
)
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
148 changes: 148 additions & 0 deletions logging.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package main

import (
"bufio"
"log"
"net"
"net/http"
"os"
"text/template"
"time"

"github.com/google/uuid"
)

type incomingTmplValues struct {
TraceID string
Method string
URL string
Protocol string
Accept string
Host string
ContentType string
}

var incomingTmpl = template.Must(template.New("").Parse(`
Incoming Request: {{ .TraceID }}
{{ .Method }} {{ .URL }} {{ .Protocol }}
Accept: {{ .Accept }}
Host: {{ .Host }}
Content-Type: {{ .ContentType }}
`))

func fromRequest(r *http.Request) *incomingTmplValues {
return &incomingTmplValues{
TraceID: r.Header.Get("Traceparent"),
Method: r.Method,
URL: r.URL.String(),
Protocol: r.Proto,
Accept: r.Header.Get("Accept"),
Host: r.Host,
ContentType: r.Header.Get("Content-Type"),
}
}

func logRequest(r *http.Request) {
err := incomingTmpl.Execute(os.Stdout, fromRequest(r))
if err != nil {
log.Printf("Error executing template: %v", err)
return
}
}

var _ http.ResponseWriter = (*ResponseWriterWrapper)(nil)
var _ http.Flusher = (*ResponseWriterWrapper)(nil)
var _ http.Hijacker = (*ResponseWriterWrapper)(nil)

type ResponseWriterWrapper struct {
w *http.ResponseWriter
f *http.Flusher
h *http.Hijacker
statusCode *int
}

func (rww ResponseWriterWrapper) Hijack() (net.Conn, *bufio.ReadWriter, error) {
return (*rww.h).Hijack()
}

func (rww ResponseWriterWrapper) Flush() {
(*rww.f).Flush()
}

func (rww ResponseWriterWrapper) Header() http.Header {
return (*rww.w).Header()
}

func (rww ResponseWriterWrapper) Write(bytes []byte) (int, error) {
return (*rww.w).Write(bytes)
}

// WriteHeader function overwrites the http.ResponseWriter WriteHeader() function
func (rww ResponseWriterWrapper) WriteHeader(statusCode int) {
*rww.statusCode = statusCode
(*rww.w).WriteHeader(statusCode)
}

// NewResponseWriterWrapper static function creates a wrapper for the http.ResponseWriter
func NewResponseWriterWrapper(w http.ResponseWriter) ResponseWriterWrapper {
var statusCode int = 200
// Every request should implement flusher
flusher, _ := w.(http.Flusher)
// Every request should implement hijacker
hijacker, _ := w.(http.Hijacker)
return ResponseWriterWrapper{
w: &w,
h: &hijacker,
f: &flusher,
statusCode: &statusCode,
}
}

type outgoingTmplValues struct {
TraceID string
Duration string
Protocol string
StatusCode int
ContentType string
}

var outgoingTmpl = template.Must(template.New("").Parse(`
Outgoing Response: {{ .TraceID }}
Duration: {{ .Duration }}
{{ .Protocol }} {{ .StatusCode }}
Content-Type: {{ .ContentType }}
`))

func fromResponse(w *ResponseWriterWrapper, r *http.Request, duration time.Duration) *outgoingTmplValues {
return &outgoingTmplValues{
TraceID: (*w.w).Header().Get("Traceparent"),
Duration: duration.String(), // To seconds
Protocol: r.Proto,
StatusCode: *w.statusCode,
ContentType: (*w.w).Header().Get("Content-Type"),
}
}

func logResponse(w *ResponseWriterWrapper, r *http.Request, duration time.Duration) {
err := outgoingTmpl.Execute(os.Stdout, fromResponse(w, r, duration))
if err != nil {
log.Printf("Error executing template: %v", err)
return
}
}

func HTTPLogging(next http.HandlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Generate a unique trace ID for the request
traceID := uuid.New().String()

// TODO: Actually add open-telemetry compatibility
r.Header.Set("Traceparent", traceID)
logRequest(r)
rww := NewResponseWriterWrapper(w)
start := time.Now()
next(rww, r)
duration := time.Since(start)
logResponse(&rww, r, duration)
})
}
15 changes: 4 additions & 11 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,10 @@ func run() int {

func server() {
mux := http.NewServeMux()
mux.Handle("/health", withCLF(health))
mux.Handle("/", withCLF(hello))
mux.Handle("/health", HTTPLogging(health))
mux.Handle("/", HTTPLogging(hello))
mux.Handle("/sse", HTTPLogging(sseHandler))
mux.Handle("/ws", HTTPLogging(wsHandler))

log.Printf("Starting up on port %s (started in %.0f seconds)", *port, time.Since(startingTime).Seconds())

Expand All @@ -72,15 +74,6 @@ func server() {
log.Fatal(http.ListenAndServe(":"+*port, mux))
}

func withCLF(next http.HandlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next(w, r)

// <remote_IP_address> - [<timestamp>] "<request_method> <request_path> <request_protocol>" -
log.Printf("%s - - [%s] \"%s %s %s\" - -", r.RemoteAddr, time.Now().Format("02/Jan/2006:15:04:05 -0700"), r.Method, r.URL.Path, r.Proto)
})
}

func hello(rw http.ResponseWriter, _ *http.Request) {
rw.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, err := rw.Write([]byte("Mimic says hello!"))
Expand Down
19 changes: 19 additions & 0 deletions requests.http
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
GET http://localhost:80/

###
GET http://localhost:80/health

###
WEBSOCKET ws://localhost:80/ws

=== wait-for-server
Hi Server!
=== wait-for-server
Are you repeating what I'm saying?
=== wait-for-server
Ok bye.


###
GET http://localhost:80/sse
Accept: text/event-stream
38 changes: 38 additions & 0 deletions sse.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package main

import (
"fmt"
"log"
"net/http"
"time"
)

func sseHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")

// Flush the headers
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
return
}

// Start sending periodic events to the client every second
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()

// Send events every second
for {
select {
case <-ticker.C:
// Send an event with the current timestamp
fmt.Fprintf(w, "data: Current time: %s\n\n", time.Now().Format(time.RFC3339))
flusher.Flush()
case <-r.Context().Done():
log.Printf("Request canceled: %v", r.Context().Err())
return
}
}
}
52 changes: 52 additions & 0 deletions websoket.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package main

import (
"log"
"net/http"

"github.com/gorilla/websocket"
)

var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(_ *http.Request) bool { return true },
}

func reader(conn *websocket.Conn) {
for {
// read in a message
messageType, p, err := conn.ReadMessage()
if err != nil {
log.Println(err)
return
}

response := "You said: " + string(p)
err = conn.WriteMessage(messageType, []byte(response))
if err != nil {
log.Println(err)
return
}
}
}

func wsHandler(w http.ResponseWriter, r *http.Request) {
// upgrade this connection to a WebSocket
// connection
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println(err)
return
}
defer ws.Close()

log.Println("Client Connected")
err = ws.WriteMessage(1, []byte("Hi Client!"))
if err != nil {
log.Println(err)
}
// listen indefinitely for new messages coming
// through on our WebSocket connection
reader(ws)
}

0 comments on commit 0794346

Please sign in to comment.