用 interface 声明,生成 Go HTTP 客户端

写一个 Go interface,给每次调用加上注解,然后运行 go generate。生成的文件实现这个 interface,业务代码用普通的 Go 类型去调用。

HTTP 声明式 Go

阅读博客文章

examples/github/api/api.go
源文件
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 里的文件,第四段是 pkg/clients/resty/example/main.go。示例页会不使用标签页、完整列出 examples/github 的文件。

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)
	}
}

为什么用 genapi

少写样板代码
你维护的是 interface。go generate 写下调用 HTTP 客户端的方法体。
类型检查
参数和返回值都是普通 Go 类型,在编译这个包时检查。
接口与实现
examples/github 把 interface 放在 api.go,把生成的实现放在 api.gen.go。
运行时的 HTTP 客户端
pkg/clients/resty/example/main.go 把 resty.DefaultClient 传给 genapi.WithHttpClient。

开始使用

1

添加模块

安装页 照录了 README 和 go.mod。README 里的安装命令是:

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

声明 interface

这是仓库里 examples/github/api/api.go 的 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
}
3

运行 go generate

README 快速开始里的命令是:

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

调用客户端

examples/github/main.go 创建客户端并调用 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)
	}
}

注解参考

基础配置

examples/github/api/api.go 在 interface 上写了 @BaseURL 和 @Header。

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 方法

examples/github 使用 @GET 和 @POST。website/api/todo.go 还使用 @PUT 和 @DELETE。解析器接受这些名字,以及 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,
	}
}

参数

路径里的 {owner} 这类占位符对应同名参数。端到端测试里的 @Query 接受一个 key 和一个 value。写成 {name} 的 value 取自同名参数。

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")

请求体

CreateIssue 接收一个 Issue。生成的方法把 Body 设为这个值。

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)
}

响应

Contributors 返回 ([]Contributor, error),生成的方法调用 genapi.HandleResponse。CreateIssue 返回 error,生成的方法调用 genapi.HandleResponse0。单个非 error 的返回值会使用 genapi.MustHandleResponse,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),
			}

请求头

GitHub interface 为每次调用设置 Accept。httpbin 端到端测试还给方法设置了请求头。两处都是 @Header 加名字和值。

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")