zeal

package module
v0.6.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 4, 2024 License: MIT Imports: 11 Imported by: 0

README

Zeal

A type-safe REST API framework for Go!

Structs can be used to define and validate URL parameters, request bodies and response types.

Params and request bodies are automatically converted to their declared types for easy use within your routes.

Automatically generates fully typed OpenAPI 3 schema documentation using REST and serves it with SwaggerUI.

Server

func main() {
    mux := zeal.NewServeMux("Example API")
    addRoutes(mux)

    specOptions := zeal.SpecOptions{
        Version:       "v0.1.0",
        Description:   "Example API description.",
        StripPkgPaths: []string{"main", "models", "github.com/DandyCodes/zeal"},
    }
    spec, err := mux.CreateSpec(specOptions)
    if err != nil {
        log.Fatalf("Failed to create API: %v", err)
    }

    serveOptions := zeal.ServeOptions{
        Port:           3975,
        Spec:           spec,
        ServeSwaggerUI: true,
        SwaggerPattern: "/swagger-ui/",
    }
    mux.ListenAndServe(serveOptions)
}

Routes

Routes handled by Zeal are automatically documented in the OpenAPI schema

This route has no response type, no URL params and no request body

zeal.Handle(mux, "POST /",
    func(response zeal.Response[any], params any, body any) error {
        fmt.Println("Hello, world!")
        return response.Status(http.StatusOK)
    })

The Status method responds with a given HTTP status code

Responses

The response type is passed as a type parameter

This route responds with an int - zeal.Response[int]

zeal.Handle(mux, "GET /the_answer",
    func(r zeal.Response[int], p any, b any) error {
        return r.JSON(42)
    })

The JSON method will only accept data of the declared response type

Here is some example data

var foodMenu = models.Menu{
    ID: 1,
    Items: []models.Item{
        {Name: "Steak", Price: 13.95},
        {Name: "Potatoes", Price: 3.95},
    },
}

var drinksMenu = models.Menu{
    ID: 2,
    Items: []models.Item{
        {Name: "Juice", Price: 1.25},
        {Name: "Soda", Price: 1.75},
    },
}

This route responds with a slice of menus - zeal.Response[[]models.Menu]

var menus = []models.Menu{foodMenu, drinksMenu}

zeal.Handle(mux, "GET /menus",
    func(r zeal.Response[[]models.Menu], p any, b any) error {
        return r.JSON(menus, http.StatusOK)
    })

The JSON method can be passed an optional HTTP status code (200 OK is sent by default)

Params

Params can be query or path params

Struct fields must begin with a capital letter

Struct representing URL params can be defined in-line

zeal.Handle(mux, "GET /menus/{ID}",
    func(r zeal.Response[models.Menu], p struct{ ID int }, b any) error {
        for _, menu := range menus {
            if menu.ID == p.ID {
                return r.JSON(menu)
            }
        }

        return r.Error(http.StatusNotFound)
    })

Params are converted to their declared type

If this fails, http.StatusUnprocessableEntity 422 is sent immediately

Bodies

Request bodies are converted to their declared type

If this fails, http.StatusUnprocessableEntity 422 is sent immediately

Struct fields must be capitalized

type PutItemsParams struct {
    Quiet bool
}
zeal.Handle(mux, "PUT /items",
    func(r zeal.Response[models.Item], p PutItemsParams, item models.Item) error {
        if item.Price < 0 {
            return r.Error(http.StatusBadRequest, "Price cannot be negative")
        }

        for i := range menus {
            for j := range menus[i].Items {
                if menus[i].Items[j].Name != item.Name {
                    continue
                }

                if !p.Quiet {
                    fmt.Println("Updating item:", item)
                }
                menus[i].Items[j].Price = item.Price
                updatedItem := menus[i].Items[j]
                return r.JSON(updatedItem)
            }
        }

        if !p.Quiet {
            fmt.Println("Creating new item:", item)
        }
        menus[1].Items = append(menus[1].Items, item)
        updatedItem := menus[1].Items[len(menus[1].Items)-1]
        return r.JSON(updatedItem, http.StatusCreated)
    })

Errors

The Error method takes an HTTP status code and an optional error message

zeal.Handle(mux, "POST /items", HandlePostItem)

func HandlePostItem(r zeal.Response[any], p struct{ MenuID int }, item models.Item) error {
    if item.Price < 0 {
        return r.Error(http.StatusBadRequest, "Price cannot be negative")
    }

    for i := range menus {
        if menus[i].ID != p.MenuID {
            continue
        }

        menus[i].Items = append(menus[i].Items, item)
        return r.Status(http.StatusCreated)
    }

    return r.Error(http.StatusNotFound)
}

The Error method must be passed a 4xx or 5xx (error) HTTP status code

If it is not passed an error code, it will respond with http.StatusInternalServerError 500 instead

Standard Library Integration

The standard library *http.Request and http.ResponseWriter can still be accessed in a zeal route

zeal.Handle(mux, "GET /",
    func(r zeal.Response[any], p any, b any) error {
        fmt.Println(r.Request)        // *http.Request
        fmt.Println(r.ResponseWriter) // http.ResponseWriter
        return nil
    })

And you can use *zeal.ServeMux to define a regular http.HandlerFunc

func DefineStandardRoute(mux *zeal.ServeMux) {
    mux.HandleFunc("GET /std", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Hello!"))
    })
}

However, routes defined this way will not be documented in the OpenAPI spec

Middleware

To add middleware such as logging, you can create a middleware stack

type loggingResponseWriter struct {
    http.ResponseWriter
    StatusCode int
}

func (w *loggingResponseWriter) WriteHeader(code int) {
    w.StatusCode = code
    w.ResponseWriter.WriteHeader(code)
}

func LoggingMiddleware[R, P, B any](next zeal.HandlerFunc[R, P, B]) zeal.HandlerFunc[R, P, B] {
    return func(r zeal.Response[R], p P, b B) error {
        start := time.Now()

        w := &loggingResponseWriter{ResponseWriter: r.ResponseWriter}
        r.ResponseWriter = w

        err := next(r, p, b)

        msg := fmt.Errorf(http.StatusText(w.StatusCode))
        if err != nil {
            msg = err
        }

        log.Println(r.Request.Method, r.Request.URL.Path, w.StatusCode, msg, time.Since(start))
        return err
    }
}

func AntiDdosMiddleware[R, P, B any](next zeal.HandlerFunc[R, P, B]) zeal.HandlerFunc[R, P, B] {
    return func(r zeal.Response[R], p P, b B) error {
        if rand.Float64() < 0.33 {
            return r.Error(http.StatusTeapot, "computer says no")
        }
        return next(r, p, b)
    }
}

Create a wrapper around zeal.Handle

func MiddlewareHandle[R, P, B any](
    mux *zeal.ServeMux, urlPattern string, handlerFunc zeal.HandlerFunc[R, P, B],
) {
    loggingHandlerFunc := LoggingMiddleware(handlerFunc)
    antiDdosHandlerFunc := AntiDdosMiddleware(loggingHandlerFunc)
    zeal.Handle(mux, urlPattern, antiDdosHandlerFunc)
}

And define your route using this wrapper

MiddlewareHandle(mux, "GET /middleware",
    func(r zeal.Response[[]models.Menu], p any, b any) error {
        if rand.Float64() < 0.33 {
            return r.Error(http.StatusInternalServerError, "an error occurred")
        } else {
            return r.JSON(menus)
        }
    })

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Handle added in v0.5.0

func Handle[T_Response, T_Params, T_Body any](mux *ServeMux, pattern string, handlerFunc HandlerFunc[T_Response, T_Params, T_Body])

Types

type HandlerFunc added in v0.3.0

type HandlerFunc[T_Response, T_Params, T_Body any] func(Response[T_Response], T_Params, T_Body) error

type Response added in v0.5.1

type Response[T_Response any] struct {
	ResponseWriter http.ResponseWriter
	Request        *http.Request
}

func (Response[T_Response]) Error added in v0.5.1

func (r Response[T_Response]) Error(status int, errorMsg ...string) error

func (*Response[T_Response]) JSON added in v0.5.1

func (r *Response[T_Response]) JSON(data T_Response, status ...int) error

func (Response[T_Response]) Status added in v0.5.1

func (r Response[T_Response]) Status(status int) error

type ServeMux added in v0.5.1

type ServeMux struct {
	*http.ServeMux
	Api *rest.API
}

func NewServeMux added in v0.5.1

func NewServeMux(apiName ...string) *ServeMux

func (*ServeMux) CreateSpec added in v0.5.2

func (mux *ServeMux) CreateSpec(options SpecOptions) (*openapi3.T, error)

func (*ServeMux) ListenAndServe added in v0.5.1

func (mux *ServeMux) ListenAndServe(options ServeOptions) error

func (*ServeMux) ServeSwaggerUI added in v0.5.1

func (mux *ServeMux) ServeSwaggerUI(spec *openapi3.T, path string) error

type ServeOptions added in v0.5.1

type ServeOptions struct {
	Port           int
	Spec           *openapi3.T
	ServeSwaggerUI bool
	SwaggerPattern string
}

type SpecOptions added in v0.5.2

type SpecOptions struct {
	Version       string
	Description   string
	StripPkgPaths []string
}

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL