Declarative HTTP clients for Go — interface in, client out.

Write a Go interface, annotate each call, and run go generate. The generated file implements that interface, and your code calls it with ordinary Go types.

HTTP Declarative Go

Read the blog post

examples/github/api/api.go
Source
package api

import (
	"context"

	"github.com/lexcao/genapi"
)

//go:generate go run ../../../cmd/genapi/main.go -file $GOFILE

// @BaseURL("https://api.github.com")
// @Header("Accept", "application/vnd.github.v3+json")
type GitHub interface {
	genapi.Interface

	// @GET("/repos/{owner}/{repo}/contributors")
	Contributors(ctx context.Context, owner string, repo string) ([]Contributor, error)

	// @POST("/repos/{owner}/{repo}/issues")
	CreateIssue(ctx context.Context, issue Issue, owner string, repo string) error
}

type Contributor struct {
	Login         string `json:"login"`
	Contributions int    `json:"contributions"`
}

type Issue struct {
	Title     string   `json:"title"`
	Body      string   `json:"body"`
	Assignees []string `json:"assignees"`
	Milestone int      `json:"milestone"`
	Labels    []string `json:"labels"`
}

Examples

The first three listings are the files in examples/github. The fourth is pkg/clients/resty/example/main.go. The examples page repeats the examples/github files without tabs.

examples/github/api/api.goSource源文件
package api

import (
	"context"

	"github.com/lexcao/genapi"
)

//go:generate go run ../../../cmd/genapi/main.go -file $GOFILE

// @BaseURL("https://api.github.com")
// @Header("Accept", "application/vnd.github.v3+json")
type GitHub interface {
	genapi.Interface

	// @GET("/repos/{owner}/{repo}/contributors")
	Contributors(ctx context.Context, owner string, repo string) ([]Contributor, error)

	// @POST("/repos/{owner}/{repo}/issues")
	CreateIssue(ctx context.Context, issue Issue, owner string, repo string) error
}

type Contributor struct {
	Login         string `json:"login"`
	Contributions int    `json:"contributions"`
}

type Issue struct {
	Title     string   `json:"title"`
	Body      string   `json:"body"`
	Assignees []string `json:"assignees"`
	Milestone int      `json:"milestone"`
	Labels    []string `json:"labels"`
}
examples/github/api/api.gen.goSource源文件
// CODE GENERATED BY genapi. DO NOT EDIT.
package api

import (
	"context"
	"github.com/lexcao/genapi"
	"net/http"
)

type implGitHub struct {
	client genapi.HttpClient
}

// SetHttpClient implments genapi.Interface
func (i *implGitHub) SetHttpClient(client genapi.HttpClient) {
	i.client = client
}

func (i *implGitHub) Contributors(ctx context.Context, owner string, repo string) ([]Contributor, error) {
	resp, err := i.client.Do(&genapi.Request{
		Method: "GET",
		Path:   "/repos/{owner}/{repo}/contributors",
		PathParams: map[string]string{
			"owner": owner,
			"repo":  repo,
		},
		Context: ctx,
	})
	return genapi.HandleResponse[[]Contributor](resp, err)
}

func (i *implGitHub) CreateIssue(ctx context.Context, issue Issue, owner string, repo string) error {
	resp, err := i.client.Do(&genapi.Request{
		Method: "POST",
		Path:   "/repos/{owner}/{repo}/issues",
		PathParams: map[string]string{
			"owner": owner,
			"repo":  repo,
		},
		Context: ctx,
		Body:    issue,
	})
	return genapi.HandleResponse0(resp, err)
}

func init() {
	genapi.Register[GitHub, *implGitHub](
		genapi.Config{
			BaseURL: "https://api.github.com",
			Header: http.Header{
				"Accept": []string{
					"application/vnd.github.v3+json",
				},
			},
		},
	)
}
examples/github/main.goSource源文件
package main

import (
	"context"
	"errors"
	"fmt"
	"io"

	"github.com/lexcao/genapi"
	"github.com/lexcao/genapi/examples/github/api"
)

func main() {
	client := genapi.New[api.GitHub]()

	contributors, err := client.Contributors(context.Background(), "lexcao", "genapi")
	if err != nil {
		fmt.Printf("failed to get contributors: %v\n", err)
		var apiErr *genapi.Error
		if errors.As(err, &apiErr) {
			body, _ := io.ReadAll(apiErr.Response.Body)
			fmt.Printf("API error: %v\n", string(body))
		}
		return
	}

	for _, contributor := range contributors {
		fmt.Printf("%s (%d)\n", contributor.Login, contributor.Contributions)
	}
}
pkg/clients/resty/example/main.goSource源文件
package main

import (
	"context"
	"errors"
	"fmt"
	"io"

	"github.com/lexcao/genapi"
	"github.com/lexcao/genapi/examples/github/api"
	"github.com/lexcao/genapi/pkg/clients/resty"
)

func main() {
	client := genapi.New[api.GitHub](
		genapi.WithHttpClient(resty.DefaultClient),
	)

	contributors, err := client.Contributors(context.Background(), "lexcao", "genapi")
	if err != nil {
		fmt.Printf("failed to get contributors: %v\n", err)
		var apiErr *genapi.Error
		if errors.As(err, &apiErr) {
			body, _ := io.ReadAll(apiErr.Response.Body)
			fmt.Printf("API error: %v\n", string(body))
		}
		return
	}

	for _, contributor := range contributors {
		fmt.Printf("%s (%d)\n", contributor.Login, contributor.Contributions)
	}
}

Why genapi?

Less boilerplate
The interface is what you maintain. go generate writes the method bodies that call the HTTP client.
Type safety
Arguments and results are ordinary Go types, checked when the package compiles.
Interface and implementation
examples/github keeps the interface in api.go and the generated implementation in api.gen.go.
Runtime HTTP client
pkg/clients/resty/example/main.go passes resty.DefaultClient to genapi.WithHttpClient.

Get started

1

Add the module

Install page quotes the README and go.mod. The module line from the README is:

README.md (line 23)Source源文件
go get github.com/lexcao/genapi
2

Declare the interface

This is the interface checked in at examples/github/api/api.go.

examples/github/api/api.go (lines 11–21)Source源文件
// @BaseURL("https://api.github.com")
// @Header("Accept", "application/vnd.github.v3+json")
type GitHub interface {
	genapi.Interface

	// @GET("/repos/{owner}/{repo}/contributors")
	Contributors(ctx context.Context, owner string, repo string) ([]Contributor, error)

	// @POST("/repos/{owner}/{repo}/issues")
	CreateIssue(ctx context.Context, issue Issue, owner string, repo string) error
}
3

Run go generate

The README quick start runs:

README.md (line 55)Source源文件
$ go generate ./api
README.md (line 35)Source源文件
//go:generate go run github.com/lexcao/genapi/cmd/genapi  -file $GOFILE
4

Call the client

examples/github/main.go constructs the client and calls Contributors.

examples/github/main.goSource源文件
package main

import (
	"context"
	"errors"
	"fmt"
	"io"

	"github.com/lexcao/genapi"
	"github.com/lexcao/genapi/examples/github/api"
)

func main() {
	client := genapi.New[api.GitHub]()

	contributors, err := client.Contributors(context.Background(), "lexcao", "genapi")
	if err != nil {
		fmt.Printf("failed to get contributors: %v\n", err)
		var apiErr *genapi.Error
		if errors.As(err, &apiErr) {
			body, _ := io.ReadAll(apiErr.Response.Body)
			fmt.Printf("API error: %v\n", string(body))
		}
		return
	}

	for _, contributor := range contributors {
		fmt.Printf("%s (%d)\n", contributor.Login, contributor.Contributions)
	}
}

Annotation reference

Base configuration

examples/github/api/api.go sets @BaseURL and @Header on the interface.

examples/github/api/api.go (lines 11–21)Source源文件
// @BaseURL("https://api.github.com")
// @Header("Accept", "application/vnd.github.v3+json")
type GitHub interface {
	genapi.Interface

	// @GET("/repos/{owner}/{repo}/contributors")
	Contributors(ctx context.Context, owner string, repo string) ([]Contributor, error)

	// @POST("/repos/{owner}/{repo}/issues")
	CreateIssue(ctx context.Context, issue Issue, owner string, repo string) error
}

HTTP methods

examples/github uses @GET and @POST. website/api/todo.go also uses @PUT and @DELETE. The parser accepts those names, plus the aliases returned by RequestLine.alias.

examples/github/api/api.go (lines 16–20)Source源文件
	// @GET("/repos/{owner}/{repo}/contributors")
	Contributors(ctx context.Context, owner string, repo string) ([]Contributor, error)

	// @POST("/repos/{owner}/{repo}/issues")
	CreateIssue(ctx context.Context, issue Issue, owner string, repo string) error
website/api/todo.go (lines 24–28)Source源文件
	// @PUT("/todos/{id}")
	UpdateTodo(ctx context.Context, id string, todo Todo) (Todo, error)

	// @DELETE("/todos/{id}")
	DeleteTodo(ctx context.Context, id string) error
internal/build/parser/annotation/declarations.go (lines 137–149)Source源文件
func (r RequestLine) alias() []string {
	return []string{
		http.MethodGet,
		http.MethodHead,
		http.MethodPost,
		http.MethodPut,
		http.MethodPatch,
		http.MethodDelete,
		http.MethodConnect,
		http.MethodOptions,
		http.MethodTrace,
	}
}

Parameters

Path placeholders such as {owner} are arguments of the same name. @Query in the end-to-end test takes a key and a value. A value written as {name} is taken from the argument of that name.

test/e2e/httpbin.go (lines 12–18)Source源文件
	// @GET("/get")
	// @Query("key", "value")
	// @Query("value", "{value}")
	// @Header("X-Hello", "world")
	Get(value string) (*Response, error)

	// @POST("/post")

Request body

CreateIssue takes an Issue value. The generated method sets Body to that value.

examples/github/api/api.go (lines 19–34)Source源文件
	// @POST("/repos/{owner}/{repo}/issues")
	CreateIssue(ctx context.Context, issue Issue, owner string, repo string) error
}

type Contributor struct {
	Login         string `json:"login"`
	Contributions int    `json:"contributions"`
}

type Issue struct {
	Title     string   `json:"title"`
	Body      string   `json:"body"`
	Assignees []string `json:"assignees"`
	Milestone int      `json:"milestone"`
	Labels    []string `json:"labels"`
}
examples/github/api/api.gen.go (lines 32–44)Source源文件
func (i *implGitHub) CreateIssue(ctx context.Context, issue Issue, owner string, repo string) error {
	resp, err := i.client.Do(&genapi.Request{
		Method: "POST",
		Path:   "/repos/{owner}/{repo}/issues",
		PathParams: map[string]string{
			"owner": owner,
			"repo":  repo,
		},
		Context: ctx,
		Body:    issue,
	})
	return genapi.HandleResponse0(resp, err)
}

Response

Contributors returns ([]Contributor, error) and the generated method calls genapi.HandleResponse. CreateIssue returns error and the generated method calls genapi.HandleResponse0. A single non-error result uses genapi.MustHandleResponse, as written in the binder.

internal/build/binder/result.go (lines 24–47)Source源文件
		switch typ {
		case "error":
			results = model.BindingResults{
				Assignment: "resp, err",
				Statement:  "genapi.HandleResponse0(resp, err)",
			}
		case "genapi.Response":
			results = model.BindingResults{
				Assignment: "resp, _",
				Statement:  "*resp",
			}
		case "*genapi.Response":
			results = model.BindingResults{
				Assignment: "resp, _",
				Statement:  "resp",
			}
		default:
			if disallowedResultPrimitives[typ] {
				return &ErrUnsupported{Message: fmt.Sprintf("type [%s] is not supported as a result type", typ)}
			}
			results = model.BindingResults{
				Assignment: "resp, err",
				Statement:  fmt.Sprintf("genapi.MustHandleResponse[%s](resp, err)", typ),
			}

Headers

The GitHub interface sets Accept on every call. The httpbin end-to-end test also sets a method header. Both use @Header with a name and a value.

examples/github/api/api.go (lines 11–21)Source源文件
// @BaseURL("https://api.github.com")
// @Header("Accept", "application/vnd.github.v3+json")
type GitHub interface {
	genapi.Interface

	// @GET("/repos/{owner}/{repo}/contributors")
	Contributors(ctx context.Context, owner string, repo string) ([]Contributor, error)

	// @POST("/repos/{owner}/{repo}/issues")
	CreateIssue(ctx context.Context, issue Issue, owner string, repo string) error
}
test/e2e/httpbin.go (lines 12–18)Source源文件
	// @GET("/get")
	// @Query("key", "value")
	// @Query("value", "{value}")
	// @Header("X-Hello", "world")
	Get(value string) (*Response, error)

	// @POST("/post")