api2

package module
v0.2.21 Latest Latest
Warning

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

Go to latest
Published: Apr 19, 2024 License: MIT Imports: 26 Imported by: 8

README

api2

godoc

Go library to make HTTP API clients and servers.

Package api2 provides types and functions used to define interfaces of client-server API and facilitate creation of server and client for it. You define a common structure (GetRoutes, see below) in Go and api2 makes both HTTP client and HTTP server for you. You do not have to do JSON encoding-decoding yourself and to duplicate schema information (data types and path) in client.

How to use this package.

Organize your code in services. Each service provides some domain specific functionality. It is a Go type whose methods correspond to exposed RPC's of the API. Each method has the following signature:

func(ctx, *Request) (*Response, error)

Let's define a service Foo with method Bar.

type Foo struct {
	...
}

type BarRequest struct {
	// These fields are stored in JSON format in body.
	Name string `json:"name"`

	// These fields are GET parameters.
	UserID int `query:"user_id"`

	// These fields are headers.
	FileHash string `header:"file_hash"`

	// These fields are cookies.
	Foo string `cookie:"foo"`

	// URL parameters present in URL template like "/path/:product".
	Product string `url:"product"`

	// These fields are skipped.
	SkippedField int `json:"-"`
}

type BarResponse struct {
	// These fields are stored in JSON format in body.
	FileSize int `json:"file_size"`

	// These fields are headers.
	FileHash string `header:"file_hash"`

	// These fields are skipped.
	SkippedField int `json:"-"`
}

func (s *Foo) Bar(ctx context.Context, req *BarRequest) (*BarResponse, error) {
	...
}

A field must not have more than one of tags: json, query, header, cookie. Fields in query, header and cookie parts are encoded and decoded with fmt.Sprintf and fmt.Sscanf. Strings are not decoded with fmt.Sscanf, but passed as is. Types implementing encoding.TextMarshaler and encoding.TextUnmarshaler are encoded and decoded using it. Cookie in Response part must be of type http.Cookie. If no field is no JSON field in the struct, then HTTP body is skipped.

You can also set HTTP status code of response by adding a field of type int with tag use_as_status:"true" to Response. 0 is interpreted as 200. If Response has status field, no HTTP statuses are considered errors.

If you need the top-level type matching body JSON to be not a struct, but of some other kind (e.g. slice or map), you should provide a field in your struct with tag use_as_body:"true":

type FooRequest struct {
	// Body of the request is JSON array of strings: ["abc", "eee", ...].
	Body []string `use_as_body:"true"`

	// You can add 'header', 'query' and 'cookie' fields here, but not 'json'.
}

If you use use_as_body:"true", you can also set is_protobuf:"true" and put a protobuf type (convertible to proto.Message) in that field. It will be sent over wire as protobuf binary form.

You can add use_as_body:"true" is_raw:"true" to a []byte field, then it will keep the whole HTTP body.

Streaming. If you use use_as_body:"true", you can also set is_stream:"true". In this case the field must be of type io.ReadCloser. On the client side put any object implementing io.ReadCloser to such a field in Request. It will be read and closed by the library and used as HTTP request body. On the server side your handler should read from the reader passed in that field of Request. (You don't have to read the entire body and to close it.) For Response, on the server side, the handler must put any object implementing io.ReadCloser to such a field of Response. The library will use it to generate HTTP response's body and close it. On the client side your code must read from that reader the entire response and then close it. If a streaming field is left nil, it is interpreted as empty body.

Now let's write the function that generates the table of routes:

func GetRoutes(s *Foo) []api2.Route {
	return []api2.Route{
		{
			Method:    http.MethodPost,
			Path:      "/v1/foo/bar/:product",
			Handler:   s.Bar,
			Transport: &api2.JsonTransport{},
		},
	}
}

You can add multiple routes with the same path, but in this case their HTTP methods must be different so that they can be distinguished.

If Transport is not set, DefaultTransport is used which is defined as &api2.JsonTransport{}.

Error handling. A handler can return any Go error. JsonTransport by default returns JSON. Error() value is put into "error" field of that JSON. If the error has HttpCode() int method, it is called and the result is used as HTTP return code. You can pass error details (any struct). For that the error must be of a custom type. You should register the error type in JsonTransport.Errors map. The key used for that error is put into "code" key of JSON and the object of the registered type - into "detail" field. The error can be wrapped using fmt.Errorf("%w" ...). See custom_error_test.go for an example.

In the server you need a real instance of service Foo to pass to GetRoutes. Then just bind the routes to http.ServeMux and run the server:

// Server.
foo := NewFoo(...)
routes := GetRoutes(foo)
api2.BindRoutes(http.DefaultServeMux, routes)
log.Fatal(http.ListenAndServe(":8080", nil))

The server is running. It serves foo.Bar function on path /v1/foo/bar with HTTP method Post.

Now let's create the client:

// Client.
routes := GetRoutes(nil)
client := api2.NewClient(routes, "http://127.0.0.1:8080")
barRes := &BarResponse{}
err := client.Call(context.Background(), barRes, &BarRequest{
	Product: "product1",
	...
})
if err != nil {
	panic(err)
}
// Server's response is in variable barRes.

The client sent request to path "/v1/foo/bar/product1", from which the server understood that product=product1.

Note that you don't have to pass a real service object to GetRoutes on client side. You can pass nil, it is sufficient to pass all needed information about request and response types in the routes table, that is used by client to find a proper route.

You can make GetRoutes accepting an interface instead of a concrete Service type. In this case you can not get method handlers by s.Bar, because this code panics if s is nil interface. As a workaround api2 provides function Method(service pointer, methodName) which you can use:

type Service interface {
	Bar(ctx context.Context, req *BarRequest) (*BarResponse, error)
}

func GetRoutes(s Service) []api2.Route {
	return []api2.Route{
		{Method: http.MethodPost, Path: "/v1/foo/bar/:product", Handler: api2.Method(&s, "Bar"), Transport: &api2.JsonTransport{}},
	}
}

If you have function GetRoutes in package foo as above you can generate static client for it in file client.go located near the file in which GetRoutes is defined:

api2.GenerateClient(foo.GetRoutes)

GenerateClient can accept multiple GetRoutes functions, but they must be located in the same package.

You can find an example in directory example. To build and run it:

$ cd api2/
$ go install ./example/...
$ app &
$ client
test
87672h0m0s
ABC XYZ
302 https://example.com/user?id=user123
secret

Code generation code is located in directory example/gen. To regenerate file client.go run:

$ go generate github.com/starius/api2/example

Documentation

Overview

Package api2 provides types and functions used to define interfaces of client-server API and facilitate creation of server and client for it.

How to use this package. Organize your code in services. Each service provides some domain specific functionality. It is a Go type whose methods correspond to exposed RPC's of the API. Each method has the following signature:

func(ctx, *Request) (*Response, error)

Let's define a service Foo with method Bar.

type Foo struct {
	...
}

type BarRequest struct {
	// These fields are stored in JSON format in body.
	Name string `json:"name"`

	// These fields are GET parameters.
	UserID int `query:"user_id"`

	// These fields are headers.
	FileHash string `header:"file_hash"`

	// These fields are cookies.
	Foo string `cookie:"foo"`

	// URL parameters present in URL template like "/path/:product".
	Product string `url:"product"`

	// These fields are skipped.
	SkippedField int `json:"-"`
}

type BarResponse struct {
	// These fields are stored in JSON format in body.
	FileSize int `json:"file_size"`

	// These fields are headers.
	FileHash string `header:"file_hash"`

	// These fields are skipped.
	SkippedField int `json:"-"`
}

func (s *Foo) Bar(ctx context.Context, req *BarRequest) (*BarResponse, error) {
	...
}

A field must not have more than one of tags: json, query, header, cookie. Fields in query, header and cookie parts are encoded and decoded with fmt.Sprintf and fmt.Sscanf. Strings are not decoded with fmt.Sscanf, but passed as is. Types implementing encoding.TextMarshaler and encoding.TextUnmarshaler are encoded and decoded using it. Cookie in Response part must be of type http.Cookie. If no field is no JSON field in the struct, then HTTP body is skipped.

You can also set HTTP status code of response by adding a field of type `int` with tag `use_as_status:"true"` to Response. 0 is interpreted as 200. If Response has status field, no HTTP statuses are considered errors.

If you need the top-level type matching body JSON to be not a struct, but of some other kind (e.g. slice or map), you should provide a field in your struct with tag `use_as_body:"true"`:

type FooRequest struct {
	// Body of the request is JSON array of strings: ["abc", "eee", ...].
	Body []string `use_as_body:"true"`

	// You can add 'header', 'query' and 'cookie' fields here, but not 'json'.
}

If you use `use_as_body:"true"`, you can also set `is_protobuf:"true"` and put a protobuf type (convertible to proto.Message) in that field. It will be sent over wire as protobuf binary form.

You can add `use_as_body:"true" is_raw:"true"` to a `[]byte` field, then it will keep the whole HTTP body.

Streaming. If you use `use_as_body:"true"`, you can also set `is_stream:"true"`. In this case the field must be of type `io.ReadCloser`. On the client side put any object implementing `io.ReadCloser` to such a field in Request. It will be read and closed by the library and used as HTTP request body. On the server side your handler should read from the reader passed in that field of Request. (You don't have to read the entire body and to close it.) For Response, on the server side, the handler must put any object implementing `io.ReadCloser` to such a field of Response. The library will use it to generate HTTP response's body and close it. On the client side your code must read from that reader the entire response and then close it. If a streaming field is left `nil`, it is interpreted as empty body.

Now let's write the function that generates the table of routes:

func GetRoutes(s *Foo) []api2.Route {
	return []api2.Route{
		{
			Method:    http.MethodPost,
			Path:      "/v1/foo/bar/:product",
			Handler:   s.Bar,
			Transport: &api2.JsonTransport{},
		},
	}
}

You can add multiple routes with the same path, but in this case their HTTP methods must be different so that they can be distinguished.

If Transport is not set, DefaultTransport is used which is defined as &api2.JsonTransport{}.

**Error handling**. A handler can return any Go error. `JsonTransport` by default returns JSON. `Error()` value is put into "error" field of that JSON. If the error has `HttpCode() int` method, it is called and the result is used as HTTP return code. You can pass error details (any struct). For that the error must be of a custom type. You should register the error type in `JsonTransport.Errors` map. The key used for that error is put into "code" key of JSON and the object of the registered type - into "detail" field. The error can be wrapped using `fmt.Errorf("%w" ...)`. See test/custom_error_test.go for an example.

In the server you need a real instance of service Foo to pass to GetRoutes. Then just bind the routes to http.ServeMux and run the server:

// Server.
foo := NewFoo(...)
routes := GetRoutes(foo)
api2.BindRoutes(http.DefaultServeMux, routes)
log.Fatal(http.ListenAndServe(":8080", nil))

The server is running. It serves foo.Bar function on path /v1/foo/bar with HTTP method Post.

Now let's create the client:

// Client.
routes := GetRoutes(nil)
client := api2.NewClient(routes, "http://127.0.0.1:8080")
barRes := &BarResponse{}
err := client.Call(context.Background(), barRes, &BarRequest{
	Product: "product1",
	...
})
if err != nil {
	panic(err)
}
// Server's response is in variable barRes.

The client sent request to path "/v1/foo/bar/product1", from which the server understood that product=product1.

Note that you don't have to pass a real service object to GetRoutes on client side. You can pass nil, it is sufficient to pass all needed information about request and response types in the routes table, that is used by client to find a proper route.

You can make GetRoutes accepting an interface instead of a concrete Service type. In this case you can not get method handlers by s.Bar, because this code panics if s is nil interface. As a workaround api2 provides function Method(service pointer, methodName) which you can use:

type Service interface {
	Bar(ctx context.Context, req *BarRequest) (*BarResponse, error)
}

func GetRoutes(s Service) []api2.Route {
	return []api2.Route{
		{Method: http.MethodPost, Path: "/v1/foo/bar/:product", Handler: api2.Method(&s, "Bar"), Transport: &api2.JsonTransport{}},
	}
}

If you have function GetRoutes in package foo as above you can generate static client for it in file client.go located near the file in which GetRoutes is defined:

api2.GenerateClient(foo.GetRoutes)

GenerateClient can accept multiple GetRoutes functions, but they must be located in the same package.

Index

Constants

This section is empty.

Variables

View Source
var CsvTransport = &JsonTransport{
	ResponseEncoder: csvEncodeResponse,
	ResponseDecoder: csvDecodeResponse,
	ErrorEncoder:    csvEncodeError,
	ErrorDecoder:    csvDecodeError,
}
View Source
var DefaultTransport = &JsonTransport{}

Functions

func BindRoutes

func BindRoutes(mux Router, routes []Route, opts ...Option)

BindRoutes adds handlers of routes to http.ServeMux.

func CustomParse

func CustomParse(t reflect.Type) (typegen.IType, bool)

func GenerateClient

func GenerateClient(getRoutes ...interface{})

GenerateClient generates file client.go with static client near the file in which passed GetRoutes function is defined.

func GenerateClientCode

func GenerateClientCode(getRoutess ...interface{}) (code, clientFile string, err error)

GenerateClientCode accepts global function GetRoutes of a package and returns the code of static client and path to the file where the code should be saved (client.go in the same directory where GetRoutes and types of requests, responses and service are defined.

func GenerateOpenApiSpec

func GenerateOpenApiSpec(options *TypesGenConfig)

func GenerateTSClient

func GenerateTSClient(options *TypesGenConfig)

func GenerateYamlClient

func GenerateYamlClient(options *YamlTypesGenConfig)

func GetMatcher

func GetMatcher(routes []Route) func(*http.Request) (*Route, bool)

GetMatcher returns a function converting http.Request to Route.

func Matches

func Matches(this *BlacklistItem, Package, Service, Handler string) bool

func Method

func Method(servicePtr interface{}, methodName string) interface{}

func SerializeCustom

func SerializeCustom(t reflect.Type) string

Types

type BlacklistItem

type BlacklistItem struct {
	Package string
	Service string
	Handler string
}

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is used on client-side to call remote methods provided by the API.

func NewClient

func NewClient(routes []Route, baseURL string, opts ...Option) *Client

NewClient creates new instance of client.

The list of routes must provide all routes that this client is aware of. Paths from the table of routes are appended to baseURL to generate final URL used by HTTP client. All pairs of (request type, response type) must be unique in the table of routes.

func (*Client) Call

func (c *Client) Call(ctx context.Context, response, request interface{}) error

Call calls remote method deduced by request and response types. Both request and response must be pointers to structs. The method must be called on exactly the same types as the corresponding method of a service.

func (*Client) Close

func (c *Client) Close() error

type Config

type Config struct {
	// contains filtered or unexported fields
}

func NewDefaultConfig

func NewDefaultConfig() *Config

type CsvResponse

type CsvResponse struct {
	HttpCode    int
	HttpHeaders http.Header
	CsvHeader   []string

	// The channel is used to pass rows of CSV file.
	//
	// In ResponseEncoder (server side) the field should be set by the handler
	// and results written from a goroutine. The transport drains the channel.
	//
	// In ResponseDecoder (client side) the channel should be passed in response
	// object. The transport downloads the file and writes rows to the channel
	// and closes it before returning.
	Rows chan []string
}

type FnInfo

type FnInfo struct {
	PkgFull    string
	PkgName    string
	StructName string
	Method     string
}

func GetFnInfo

func GetFnInfo(i interface{}) FnInfo

type FuncInfoer

type FuncInfoer interface {
	FuncInfo() (pkgFull, pkgName, structName, method string)
}

type HttpClient

type HttpClient interface {
	Do(req *http.Request) (*http.Response, error)
	CloseIdleConnections()
}

type HttpError

type HttpError interface {
	HttpCode() int
}

type JsonTransport

type JsonTransport struct {
	RequestDecoder  func(context.Context, *http.Request, interface{}) (context.Context, error)
	ResponseEncoder func(context.Context, http.ResponseWriter, interface{}) error
	ErrorEncoder    func(context.Context, http.ResponseWriter, error) error
	RequestEncoder  func(ctx context.Context, method, url string, req interface{}) (*http.Request, error)
	ResponseDecoder func(context.Context, *http.Response, interface{}) error
	ErrorDecoder    func(context.Context, *http.Response) error

	// Errors whose structure is preserved and parsed back by api2 Client.
	// Values in the map are sample objects of error types. Keys in the map
	// are user-provided names of such errors. This value is passed in a
	// separate JSON field ("detail") as well as its type (in JSON field
	// "code"). Other errors are reduced to their messages.
	Errors map[string]error
}

JsonTransport implements interface Transport for JSON encoding of requests and responses.

It recognizes GET parameter "human". If it is set, JSON in the response is pretty formatted.

To redefine some methods, set corresponding fields in the struct:

&JsonTransport{RequestDecoder: func ...

func (*JsonTransport) BodyCloseNeeded added in v0.2.1

func (h *JsonTransport) BodyCloseNeeded(ctx context.Context, response, request interface{}) bool

func (*JsonTransport) DecodeError

func (h *JsonTransport) DecodeError(ctx context.Context, res *http.Response) error

func (*JsonTransport) DecodeRequest

func (h *JsonTransport) DecodeRequest(ctx context.Context, r *http.Request, req interface{}) (context.Context, error)

func (*JsonTransport) DecodeResponse

func (h *JsonTransport) DecodeResponse(ctx context.Context, res *http.Response, response interface{}) error

func (*JsonTransport) DecodeResponseAndError added in v0.2.4

func (h *JsonTransport) DecodeResponseAndError(ctx context.Context, httpRes *http.Response, res interface{}) error

func (*JsonTransport) EncodeError

func (h *JsonTransport) EncodeError(ctx context.Context, w http.ResponseWriter, err error) error

func (*JsonTransport) EncodeRequest

func (h *JsonTransport) EncodeRequest(ctx context.Context, method, urlStr string, req interface{}) (*http.Request, error)

func (*JsonTransport) EncodeResponse

func (h *JsonTransport) EncodeResponse(ctx context.Context, w http.ResponseWriter, res interface{}) error

type Option

type Option func(*Config)

func AuthorizationHeader

func AuthorizationHeader(authorization string) Option

func CustomClient

func CustomClient(client HttpClient) Option

func ErrorLogger

func ErrorLogger(logger func(format string, args ...interface{})) Option

func HumanJSON added in v0.2.8

func HumanJSON(enabled bool) Option

Always produce pretty formatted JSON on both client and server.

func MaxBody

func MaxBody(maxBody int64) Option

type Route

type Route struct {
	// HTTP method.
	Method string

	// HTTP path. The same path can be used multiple times with different methods.
	Path string

	// Handler is a function with the following signature:
	// func(ctx, *Request) (*Response, error)
	// Request and Response are custom structures, unique to this route.
	Handler interface{}

	// The transport used in this route. If Transport is not set, DefaultTransport
	// is used.
	Transport Transport

	// Meta is optional field to put arbitrary data about the route.
	// E.g. the list of users who are allowed to use the route.
	Meta map[string]interface{}
}

Route describes one endpoint in the API, associated with particular method of some service.

type Router added in v0.2.6

type Router interface {
	HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request))
}

type Transport

type Transport interface {
	// Called by server.
	DecodeRequest(ctx context.Context, r *http.Request, req interface{}) (context.Context, error)
	EncodeResponse(ctx context.Context, w http.ResponseWriter, res interface{}) error
	EncodeError(ctx context.Context, w http.ResponseWriter, err error) error

	// Called by client.
	EncodeRequest(ctx context.Context, method, url string, req interface{}) (*http.Request, error)
	DecodeResponse(ctx context.Context, httpRes *http.Response, res interface{}) error
	DecodeError(ctx context.Context, httpRes *http.Response) error
}

Transport converts back and forth between HTTP and Request, Response types.

type TypesGenConfig

type TypesGenConfig struct {
	OutDir          string
	ClientTemplate  *template.Template
	Routes          []interface{}
	Types           []interface{}
	Blacklist       []BlacklistItem
	EnumsWithPrefix bool
}

type YamlTypesGenConfig

type YamlTypesGenConfig struct {
	OutDir         string
	ClientTemplate *template.Template
	Routes         []interface{}
	Blacklist      []BlacklistItem
}

Directories

Path Synopsis
app
gen

Jump to

Keyboard shortcuts

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