Skip to content

mmbednarek/smartapi

Repository files navigation

SmartAPI REST Library

GoDoc Build Status Go Report Card Coverage Status

SmartAPI allows to quickly implement solid REST APIs in Golang. The idea behind the project is to replace handler functions with ordinary looking functions. This allows service layer methods to be used as handlers. Designation of a dedicated API layer is still advisable in order to map errors to status codes, write cookies, headers, etc.

SmartAPI is based on github.com/go-chi/chi. This allows Chi middlewares to be used.

Examples

This example returns a greeting with a name based on a query param name.

package main

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

    "github.com/mmbednarek/smartapi"
)

func MountAPI() http.Handler {
    r := smartapi.NewRouter()
    r.Get("/greeting", Greeting,
        smartapi.QueryParam("name"),
    )

    return r.MustHandler()
}

func Greeting(name string) string {
    return fmt.Sprintf("Hello %s!\n", name)
}

func main() {
    log.Fatal(http.ListenAndServe(":8080", MountAPI()))
}
$ curl '127.0.0.1:8080/greeting?name=Johnny'
Hello Johnny!

It's possible to use even standard Go functions

But it's a good practice to use your own handler functions for your api.

package main

import (
    "encoding/base32"
    "encoding/base64"
    "log"
    "net/http"

    "github.com/mmbednarek/smartapi"
)

func MountAPI() http.Handler {
    r := smartapi.NewRouter()

    r.Route("/encode", func(r smartapi.Router) {
        r.Post("/base64", base64.StdEncoding.EncodeToString)
        r.Post("/base32", base32.StdEncoding.EncodeToString)
    }, smartapi.ByteSliceBody())
    r.Route("/decode", func(r smartapi.Router) {
        r.Post("/base64", base64.StdEncoding.DecodeString)
        r.Post("/base32", base32.StdEncoding.DecodeString)
    }, smartapi.StringBody())

    return r.MustHandler()
}

func main() {
    log.Fatal(http.ListenAndServe(":8080", MountAPI()))
}
~ $ curl 127.0.0.1:8080/encode/base64 -d 'smartAPI'
c21hcnRBUEk=
~ $ curl 127.0.0.1:8080/encode/base32 -d 'smartAPI'
ONWWC4TUIFIES===
~ $ curl 127.0.0.1:8080/decode/base64 -d 'c21hcnRBUEk='
smartAPI
~ $ curl 127.0.0.1:8080/decode/base32 -d 'ONWWC4TUIFIES==='
smartAPI

Service example

You can use SmartAPI with service layer methods as shown here.

package main

import (
    "log"
    "net/http"


    "github.com/mmbednarek/smartapi"
)

type Date struct {
    Day   int `json:"day"`
    Month int `json:"month"`
    Year  int `json:"year"`
}

type User struct {
    Login       string `json:"login"`
    Password    string `json:"password,omitempty"`
    Email       string `json:"email"`
    DateOfBirth Date   `json:"date_of_birth"`
}

type Service interface {
    RegisterUser(user *User) error
    Auth(login, password string) (string, error)
    GetUserData(session string) (*User, error)
    UpdateUser(session string, user *User) error
}

func newHandler(service Service) http.Handler {
    r := smartapi.NewRouter()

    r.Post("/user", service.RegisterUser,
        smartapi.JSONBody(User{}),
    )
    r.Post("/user/auth", service.Auth,
        smartapi.PostQueryParam("login"),
        smartapi.PostQueryParam("password"),
    )
    r.Get("/user", service.GetUserData,
        smartapi.Header("X-Session-ID"),
    )
    r.Patch("/user", service.UpdateUser,
        smartapi.Header("X-Session-ID"),
        smartapi.JSONBody(User{}),
    )

    return r.MustHandler()
}

func main() {
    svr := service.NewService() // Your service implementation
    log.Fatal(http.ListenAndServe(":8080", newHandler(svr)))
}

Middlewares

Middlewares can be used just as in Chi. Use(...) appends middlewares to be used. With(...) creates a copy of a router with chosen middlewares.

Routing

Routing works similarity to Chi routing. Parameters can be prepended to be used in all endpoints in that route.

r.Route("/v1/foo", func(r smartapi.Router) {
    r.Route("/bar", func(r smartapi.Router) {
        r.Get("/test", func(ctx context.Context, foo string, test string) {
            ...
        },
            smartapi.QueryParam("test"),
        )
    },
        smartapi.Header("X-Foo"),
    )
},
    smartapi.Context(),
)

Support for legacy handlers

Legacy handlers are supported with no overhead. They are directly passed as ordinary handler functions. No additional variadic arguments are required for legacy handler to be used.

Handler response

Empty body response

A handler function with error only return argument will return empty response body with 204 NO CONTENT status as default.

r.Post("/test", func() error {
    return nil
})

String response

Returned string will we written directly into a function body.

r.Get("/test", func() (string, error) {
    return "Hello World", nil
})

Byte slice response

Just as with the string, the slice will we written directly into a function body.

r.Get("/test", func() ([]byte, error) {
    return []byte("Hello World"), nil
})

Struct, pointer or interface response

A struct, a pointer, an interface or a slice different than a byte slice with be encoded into a json format.

r.Get("/test", func() (interface{}, error) {
    return struct{
        Name string `json:"name"`
        Age  int    `json:"age"`
    }{"John", 34}, nil
})

Errors

To return an error with a status code you can use one of the error functions: smartapi.Error(status int, msg, reason string), smartapi.Errorf(status int, msg string, fmt ...interface{}), smartapi.WrapError(status int, err error, reason string). The API error contains an error message and an error reason. The message will be printed with a logger. The reason will be returned in the response body. You can also return ordinary errors. They are treated as if their status code was 500.

r.Get("/order/{id}", func(orderID string) (*Order, error) {
    order, err := db.GetOrder(orderID)
    if err != nil {
        if errors.Is(err, ErrNoSuchOrder) {
            return nil, smartapi.WrapError(http.StatusNotFound, err, "no such order")
        }
        return nil, err
    }
    return order, nil
},
    smartapi.URLParam("id"),
)
$ curl -i 127.0.0.1:8080/order/someorder
HTTP/1.1 404 Not Found
Date: Sun, 22 Mar 2020 14:17:34 GMT
Content-Length: 40
Content-Type: text/plain; charset=utf-8

{"status":404,"reason":"no such order"}

Endpoint arguments

List of available endpoint attributes

Request Struct

Request can be passed into a structure's field by tags.

type headers struct {
    Foo string `smartapi:"header=X-Foo"`
    Bar string `smartapi:"header=X-Bar"`
}
r.Post("/user", func(h *headers) (string, error) {
    return fmt.Sprintf("Foo: %s, Bar: %s\n", h.Foo, h.Bar), nil
},
    smartapi.RequestStruct(headers{}),
)

Every argument has a tag value equivalent

Tag Value Function Equivalent Expected Type
header=name Header("name") string
r_header=name RequiredHeader("name") string
json_body JSONBody() ...
string_body StringBody() string
byte_slice_body ByteSliceBody() []byte
body_reader BodyReader() io.Reader
url_param=name URLParam("name") string
context Context() context.Context
query_param=name QueryParam("name") string
r_query_param=name RequiredQueryParam("name") string
post_query_param=name PostQueryParam("name") string
r_post_query_param=name RequiredPostQueryParam("name") string
cookie=name Cookie("name") string
r_cookie=name RequiredCookie("name") string
response_headers ResponseHeaders() smartapi.Headers
response_cookies ResponseCookies() smartapi.Cookies
response_writer ResponseWriter() http.ResponseWriter
request Request() *http.Request
request_struct RequestStruct() struct{...}
as_int=header=name AsInt(Header("name") int
as_byte_slice=header=name AsByteSlice(Header("name") []byte

JSON Body

JSON Body unmarshals the request's body into a given structure type. Expects a pointer to that structure as a function argument. If you want to use the object directly (not as a pointer) you can use JSONBodyDirect.

r.Post("/user", func(u *User) error {
    return db.AddUser(u)
},
    smartapi.JSONBody(User{}),
)

String Body

String body passes the request's body as a string.

r.Post("/user", func(body string) error {
    fmt.Printf("Request body: %s\n", body)
    return nil
},
    smartapi.StringBody(),
)

Byte Slice Body

Byte slice body passes the request's body as a byte slice.

r.Post("/user", func(body []byte) error {
    fmt.Printf("Request body: %s\n", string(body))
    return nil
},
    smartapi.ByteSliceBody(),
)

Body Reader

Byte reader body passes the io.Reader interface to read request's body.

r.Post("/user", func(body io.Reader) error {
    buff, err := ioutil.ReadAll()
    if err != nil {
        return err
    }
    return nil
},
    smartapi.BodyReader(),
)

Response Writer

Classic http.ResponseWriter can be used as well.

r.Post("/user", func(w http.ResponseWriter) error {
    _, err := w.Write([]byte("RESPONSE"))
    if err != nil {
        return err
    }
    return nil
},
    smartapi.ResponseWriter(),
)

Request

Classic *http.Request can be passed as an argument.

r.Post("/user", func(r *http.Request) error {
    buff, err := ioutil.ReadAll(r.Body)
    if err != nil {
        return err
    }
    fmt.Printf("Request body is: %s\n", string(buff))
    return nil
},
    smartapi.Request(),
)

Query param

Query param reads the value of the selected param and passes it as a string to function.

r.Get("/user", func(name string) (*User, error) {
    return db.GetUser(name)
},
    smartapi.QueryParam("name"),
)

Required Query param

Like QueryParam() but returns 400 BAD REQUEST when empty.

r.Get("/user", func(name string) (*User, error) {
    return db.GetUser(name)
},
    smartapi.RequiredQueryParam("name"),
)

Post Query param

Reads a query param from requests body.

r.Get("/user", func(name string) (*User, error) {
    return db.GetUser(name)
},
    smartapi.PostQueryParam("name"),
)

Required Post Query param

Like PostQueryParam() but returns 400 BAD REQUEST when empty.

r.Get("/user", func(name string) (*User, error) {
    return db.GetUser(name)
},
    smartapi.RequiredPostQueryParam("name"),
)

URL param

URL uses read chi's URL param and passes it into a function as a string.

r.Get("/user/{name}", func(name string) (*User, error) {
    return db.GetUser(name)
},
    smartapi.URLParam("name"),
)

Header

Header reads the value of the selected request header and passes it as a string to function.

r.Get("/example", func(test string) (string, error) {
    return fmt.Sprintf("The X-Test headers is %s", test), nil
},
    smartapi.Header("X-Test"),
)

Required Header

Like Header(), but responds with 400 BAD REQUEST, if the header is not present.

r.Get("/example", func(test string) (string, error) {
    return fmt.Sprintf("The X-Test headers is %s", test), nil
},
    smartapi.RequiredHeader("X-Test"),
)

Cookie

Reads a cookie from the request and passes the value into a function as a string.

r.Get("/example", func(c string) (string, error) {
    return fmt.Sprintf("cookie: %s", c)
},
    smartapi.Cookie("cookie"),
)

Required Cookie

Like Cookie(), but returns 400 BAD REQUEST when empty.

r.Get("/example", func(c string) (string, error) {
    return fmt.Sprintf("cookie: %s", c)
},
    smartapi.RequiredCookie("cookie"),
)

Context

Context passes r.Context() into a function.

r.Get("/example", func(ctx context.Context) (string, error) {
    return fmt.Sprintf("ctx: %s", ctx)
},
    smartapi.Context(),
)

ResponseHeaders

Response headers allows an endpoint to add response headers.

r.Get("/example", func(headers smartapi.Headers) error {
    headers.Set("Api-Version", "1.2.3")
    return nil
},
    smartapi.ResponseHeaders(),
)

ResponseCookies

Response cookies allows an endpoint to easily add Set-Cookie header.

r.Get("/example", func(cookies smartapi.Cookies) error {
    cookies.Add(&http.Cookie{Name: "Foo", Value: "Bar"})
    return nil
},
    smartapi.ResponseCookies(),
)

Casts

Request attributes can be automatically casted to desired type.

AsInt

r.Get("/example", func(value int) error {
    ...
    return nil
},
    smartapi.AsInt(smartapi.Header("Value")),
)

AsByteSlice

r.Get("/example", func(value []byte) error {
    ...
    return nil
},
    smartapi.AsByteSlice(smartapi.Header("Value")),
)

Conversion to int

Endpoint attributes

Response status

ResponseStatus allows the response status to be set for endpoints with empty response body. Default status is 204 NO CONTENT.

r.Get("/example", func() error {
    return nil
},
    smartapi.ResponseStatus(http.StatusCreated),
)

Releases

No releases published

Packages

 
 
 

Languages