Skip to content

Commit

Permalink
support for new ServeMux functionality (#35)
Browse files Browse the repository at this point in the history
  • Loading branch information
jakecoffman committed Feb 7, 2024
1 parent 221cb72 commit 147fca2
Show file tree
Hide file tree
Showing 5 changed files with 382 additions and 10 deletions.
16 changes: 7 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,16 @@ OpenAPI is great, but up until now your options to use it are:

None of these options seems like a great idea.

This project takes another approach: make a specification in Go code using nice builders where possible. The OpenAPI spec is generated from this and validation is done before your handler gets called.
This project takes another approach: make a specification in Go code using type-safe builders where possible. The OpenAPI spec is generated from this and validation is done before your handler gets called.

This reduces boilerplate that you have to write and gives you nice documentation too!

### Examples

- [Full Gin-Gonic Example](adapters/gin-adapter/example)
- [Full Echo Example](adapters/echo-adapter/example)
- [Full Gorilla Mux Example](adapters/gorilla-adapter/example)

### Builtin ServeMux not supported

This is disappointing, but the builtin http.ServeMux is not supported because it doesn't support routing by method and doesn't support path params. This project is NOT a router so it will not try to reinvent these features.
- [ServeMux Example](_example/main.go)
- [Gin-Gonic Example](adapters/gin-adapter/example)
- [Echo Example](adapters/echo-adapter/example)
- [Gorilla Mux Example](adapters/gorilla-adapter/example)

### Getting started

Expand All @@ -60,7 +57,7 @@ crud.Spec{
Validate: crud.Validate{
Path: crud.Object(map[string]crud.Field{
"id": crud.Number().Required().Description("ID of the widget"),
}),
}),
Body: crud.Object(map[string]crud.Field{
"owner": crud.String().Required().Example("Bob").Description("Widget owner's name"),
"quantity": crud.Integer().Min(1).Default(1).Description("The amount requested")
Expand All @@ -76,3 +73,4 @@ It mounts the swagger-ui at `/` and loads up the generated swagger.json:
![screenshot](/screenshot.png?raw=true "Swagger")

The `PreHandlers` run before validation, and the `Handler` runs after validation is successful.

128 changes: 128 additions & 0 deletions _example/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package main

import (
"encoding/json"
"github.com/jakecoffman/crud"
"log"
"math/rand"
"net/http"
)

// This example uses the built-in ServeMux with added functionality in Go 1.22.
// To see examples using other routers go to the adapaters directory.

func main() {
r := crud.NewRouter("Widget API", "1.0.0", crud.NewServeMuxAdapter())

if err := r.Add(Routes...); err != nil {
log.Fatal(err)
}

log.Println("Serving http://127.0.0.1:8080")
err := r.Serve("127.0.0.1:8080")
if err != nil {
log.Println(err)
}
}

var tags = []string{"Widgets"}

var Routes = []crud.Spec{{
Method: "GET",
Path: "/widgets",
Handler: ok,
Description: "Lists widgets",
Tags: tags,
Validate: crud.Validate{
Query: crud.Object(map[string]crud.Field{
"limit": crud.Number().Required().Min(0).Max(25).Description("Records to return"),
"ids": crud.Array().Items(crud.Number()),
}),
},
}, {
Method: "POST",
Path: "/widgets",
PreHandlers: fakeAuthPreHandler,
Handler: bindAndOk,
Description: "Adds a widget",
Tags: tags,
Validate: crud.Validate{
Body: crud.Object(map[string]crud.Field{
"name": crud.String().Required().Example("Bob"),
"arrayMatey": crud.Array().Items(crud.Number()),
}),
},
Responses: map[string]crud.Response{
"200": {
Schema: crud.JsonSchema{
Type: crud.KindObject,
Properties: map[string]crud.JsonSchema{
"hello": {Type: crud.KindString},
},
},
Description: "OK",
},
},
}, {
Method: "GET",
Path: "/widgets/{id}",
Handler: ok,
Description: "Updates a widget",
Tags: tags,
Validate: crud.Validate{
Path: crud.Object(map[string]crud.Field{
"id": crud.Number().Required(),
}),
},
}, {
Method: "PUT",
Path: "/widgets/{id}",
Handler: bindAndOk,
Description: "Updates a widget",
Tags: tags,
Validate: crud.Validate{
Path: crud.Object(map[string]crud.Field{
"id": crud.Number().Required(),
}),
Body: crud.Object(map[string]crud.Field{
"name": crud.String().Required(),
}),
},
}, {
Method: "DELETE",
Path: "/widgets/{id}",
Handler: ok,
Description: "Deletes a widget",
Tags: tags,
Validate: crud.Validate{
Path: crud.Object(map[string]crud.Field{
"id": crud.Number().Required(),
}),
},
},
}

func fakeAuthPreHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if rand.Intn(2) == 0 {
w.WriteHeader(http.StatusTeapot)
_, _ = w.Write([]byte("Random rejection from PreHandler"))
return
}
next.ServeHTTP(w, r)
})
}

func ok(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(r.URL.Query())
}

func bindAndOk(w http.ResponseWriter, r *http.Request) {
var widget interface{}
if err := json.NewDecoder(r.Body).Decode(&widget); err != nil {
w.WriteHeader(400)
_ = json.NewEncoder(w).Encode(err.Error())
return
}
_ = json.NewEncoder(w).Encode(widget)
}
140 changes: 140 additions & 0 deletions adapter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package crud

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"reflect"
)

type ServeMuxAdapter struct {
Engine *http.ServeMux
}

func NewServeMuxAdapter() *ServeMuxAdapter {
return &ServeMuxAdapter{
Engine: http.NewServeMux(),
}
}

type MiddlewareFunc func(http.Handler) http.Handler

func (a *ServeMuxAdapter) Install(r *Router, spec *Spec) error {
middlewares := []MiddlewareFunc{
validateHandlerMiddleware(r, spec),
}

switch v := spec.PreHandlers.(type) {
case nil:
case []MiddlewareFunc:
middlewares = append(middlewares, v...)
case MiddlewareFunc:
middlewares = append(middlewares, v)
case func(http.Handler) http.Handler:
middlewares = append(middlewares, v)
default:
return fmt.Errorf("PreHandlers must be MiddlewareFunc, got: %v", reflect.TypeOf(spec.Handler))
}

var finalHandler http.Handler
switch v := spec.Handler.(type) {
case nil:
return fmt.Errorf("handler must not be nil")
case http.HandlerFunc:
finalHandler = v
case func(http.ResponseWriter, *http.Request):
finalHandler = http.HandlerFunc(v)
case http.Handler:
finalHandler = v
default:
return fmt.Errorf("handler must be http.HandlerFunc, got %v", reflect.TypeOf(spec.Handler))
}

// install the route, use a subrouter so the "use" is scoped
path := fmt.Sprintf("%s %s", spec.Method, spec.Path)
subrouter := http.NewServeMux()
subrouter.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
handler := finalHandler
for i := len(middlewares) - 1; i >= 0; i-- {
handler = middlewares[i](handler)
}
handler.ServeHTTP(w, r)
})
a.Engine.Handle(path, subrouter)

return nil
}

func (a *ServeMuxAdapter) Serve(swagger *Swagger, addr string) error {
a.Engine.HandleFunc("GET /swagger.json", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(swagger)
})

a.Engine.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "text/html")
_, err := w.Write(SwaggerUiTemplate)
if err != nil {
panic(err)
}
})

return http.ListenAndServe(addr, a.Engine)
}

func validateHandlerMiddleware(router *Router, spec *Spec) MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
val := spec.Validate
var query url.Values
var body interface{}
var path map[string]string

if val.Path.Initialized() {
path = map[string]string{}
for name := range val.Path.obj {
path[name] = r.PathValue(name)
}
}

var rewriteBody bool
if val.Body.Initialized() && val.Body.Kind() != KindFile {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
w.WriteHeader(400)
_ = json.NewEncoder(w).Encode("failure decoding body: " + err.Error())
return
}
rewriteBody = true
}

var rewriteQuery bool
if val.Query.Initialized() {
query = r.URL.Query()
rewriteQuery = true
}

if err := router.Validate(val, query, body, path); err != nil {
w.WriteHeader(400)
_ = json.NewEncoder(w).Encode(err.Error())
return
}

// Validate can strip values that are not valid, so we rewrite them
// after validation is complete. Can't use defer as in other adapters
// because next.ServeHTTP calls the next handler and defer hasn't
// run yet.
if rewriteBody {
data, _ := json.Marshal(body)
_ = r.Body.Close()
r.Body = io.NopCloser(bytes.NewReader(data))
}
if rewriteQuery {
r.URL.RawQuery = query.Encode()
}

next.ServeHTTP(w, r)
})
}
}
Loading

0 comments on commit 147fca2

Please sign in to comment.