Gateway plugins extend the core features of an API gateway to meet complex business requirements. This guide shows you how to develop a gateway plugin in Go and how to debug it locally.
Higress has migrated from the TinyGo 0.29 and Go 1.20 compilation method to the native Wasm compilation supported in Go 1.24. Go 1.24 now provides native support for compiling .wasm files.
If you previously used TinyGo to compile plugins and want to migrate to the Go 1.24 compilation mode, you must move the plugin initialization logic from the main function to the init function and adjust the dependencies in your go.mod file. The following sections provide a specific example.
When you migrate existing TinyGo-based plugins, consider the following compatibility adjustments:
1. If you call an external service during the header processing stage and return type.ActionPause, you must change the return value to types.HeaderStopAllIterationAndWatermark. For a reference implementation, see the "Call an external service from a plugin" example later in this topic.
2. If you used the go-re2 library due to incomplete support for the standard regexp library in TinyGo, you must now replace it with the official Go regexp package.
Prerequisites
You must have Go installed.
Golang
Follow the official installation guide. You must use Go version 1.24 or later.
Plugins compiled with Go 1.24 require a Cloud-native API Gateway version of 2.1.5 or later. For earlier gateway versions, see Develop WASM plugins by using the Go language.
Windows
Download the installation file.
Open the downloaded file to start the installation. By default, Go is installed in the
Program FilesorProgram Files (x86)directory.After the installation is successful, press Win+R to open the Run window. In the Run window, enter
cmdand click OK to open the command prompt. Enter thego versioncommand. The installation is successful if the output displays the current version.
macOS
Download the installation package.
Double-click the downloaded package file to start the installation. By default, the package is installed in the
/usr/local/godirectory.Open a terminal and run the
go versioncommand. If the current version is returned, the installation is successful.
Linux
Download the installation package.
Run the following commands to install Go.
Install Go.
rm -rf /usr/local/go && tar -C /usr/local -xzf go1.24.4.linux-amd64.tar.gzConfigure the environment variable.
export PATH=$PATH:/usr/local/go/binRun
go version. The command outputs the currently installed version, which indicates that the installation was successful.
Develop the plugin
Initialize the project directory
-
Create a project directory, such as
wasm-demo-go. -
In the directory, run the following command to initialize the Go module.
go mod init wasm-demo-go -
If you are in the Chinese mainland, you may need to set a proxy for downloading dependencies.
go env -w GOPROXY=https://proxy.golang.com.cn,direct -
Download the dependencies for the plugin.
go get github.com/higress-group/proxy-wasm-go-sdk@go-1.24 go get github.com/higress-group/wasm-go@main go get github.com/tidwall/gjson
Write the main.go file
The following is a simple example. If you set the plugin configuration to mockEnable: true, the plugin directly returns a hello world response. If you do not configure the plugin or set the configuration to mockEnable: false, the plugin adds the hello: world request header to the original request. For more examples, see Section 4.
The plugin configuration is written in YAML in the console but is converted to JSON before being delivered to the plugin. Therefore, the parseConfig function in the example parses the configuration directly from JSON data.
package main
import (
"github.com/higress-group/wasm-go/pkg/wrapper"
logs "github.com/higress-group/wasm-go/pkg/log"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
"github.com/tidwall/gjson"
)
func main() {}
func init() {
wrapper.SetCtx(
// Plugin name
"my-plugin",
// Set a custom function to parse the plugin configuration
wrapper.ParseConfigBy(parseConfig),
// Set a custom function to process request headers
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
)
}
// Custom plugin configuration
type MyConfig struct {
mockEnable bool
}
// The YAML configuration from the console is automatically converted to JSON.
// This function parses the configuration from the JSON parameter.
func parseConfig(json gjson.Result, config *MyConfig, log logs.Log) error {
// Parse the configuration and update the config struct.
config.mockEnable = json.Get("mockEnable").Bool()
return nil
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, config MyConfig, log logs.Log) types.Action {
proxywasm.AddHttpRequestHeader("hello", "world")
if config.mockEnable {
proxywasm.SendHttpResponse(200, nil, []byte("hello world"), -1)
}
return types.HeaderContinue
}
HTTP processing mount points
In the preceding example code, by using wrapper.ProcessRequestHeadersBy, the custom function onHttpRequestHeaders is called to process requests during the HTTP request header processing phase. In addition, you can set custom processing functions for other phases in the following ways.
|
Processing stage |
Trigger |
Mount point |
|
HTTP request header processing |
The gateway receives request header data from the client. |
|
|
HTTP request body processing |
The gateway receives request body data from the client. |
|
|
HTTP response header processing |
The gateway receives response header data from the backend service. |
|
|
HTTP response body processing |
The gateway receives response body data from the backend service. |
|
Utility methods
In the preceding code example, proxywasm.AddHttpRequestHeader and proxywasm.SendHttpResponse are two utility methods provided by the plugin SDK. The main utility methods are described in the following table.
|
Category |
Method name |
Description |
Effective stage |
|
Request header processing |
GetHttpRequestHeaders |
Gets all request headers from the client request. |
HTTP request header processing |
|
ReplaceHttpRequestHeaders |
Replaces all request headers of the client request. |
HTTP request header processing |
|
|
GetHttpRequestHeader |
Gets a specific request header from the client request. |
HTTP request header processing |
|
|
RemoveHttpRequestHeader |
Removes a specific request header from the client request. |
HTTP request header processing |
|
|
ReplaceHttpRequestHeader |
Replaces a specific request header of the client request. |
HTTP request header processing |
|
|
AddHttpRequestHeader |
Adds a request header to the client request. |
HTTP request header processing |
|
|
Request body processing |
GetHttpRequestBody |
Gets the request body. |
HTTP request body processing |
|
AppendHttpRequestBody |
Appends a byte string to the request body. |
HTTP request body processing |
|
|
PrependHttpRequestBody |
Prepends a byte string to the request body. |
HTTP request body processing |
|
|
ReplaceHttpRequestBody |
Replaces the request body. |
HTTP request body processing |
|
|
Response header processing |
GetHttpResponseHeaders |
Gets all response headers from the backend service. |
HTTP response header processing |
|
ReplaceHttpResponseHeaders |
Replaces all response headers of the backend service. |
HTTP response header processing |
|
|
GetHttpResponseHeader |
Gets a specific response header from the backend service. |
HTTP response header processing |
|
|
RemoveHttpResponseHeader |
Removes a specific response header from the backend service. |
HTTP response header processing |
|
|
ReplaceHttpResponseHeader |
Replaces a specific response header of the backend service. |
HTTP response header processing |
|
|
AddHttpResponseHeader |
Adds a response header to the backend service. |
HTTP response header processing |
|
|
Response body processing |
GetHttpResponseBody |
Gets the response body from the backend service. |
HTTP response body processing |
|
AppendHttpResponseBody |
Appends a byte string to the response body of the backend service. |
HTTP response body processing |
|
|
PrependHttpResponseBody |
Prepends a byte string to the response body of the backend service. |
HTTP response body processing |
|
|
ReplaceHttpResponseBody |
Replaces the response body of the backend service. |
HTTP response body processing |
|
|
HTTP call |
DispatchHttpCall |
Sends an HTTP request. |
- |
|
GetHttpCallResponseHeaders |
Gets the response headers of a |
- |
|
|
GetHttpCallResponseBody |
Gets the response body of a |
- |
|
|
GetHttpCallResponseTrailers |
Gets the response trailers of a |
- |
|
|
Direct response |
SendHttpResponse |
Sends a direct HTTP response, bypassing the backend service. |
- |
|
Flow resumption |
ResumeHttpRequest |
Resumes a previously paused request processing flow. |
- |
|
ResumeHttpResponse |
Resumes a previously paused response processing flow. |
- |
Do not call ResumeHttpRequest or ResumeHttpResponse when a request or response is not paused. Calling SendHttpResponse automatically resumes a paused request or response. Therefore, calling them again may cause undefined behavior.
Compile the .wasm file
Compile the .wasm file locally
Run the following command to compile the .wasm file.
go mod tidy
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o main.wasm ./
A successful compilation generates a main.wasm file. This file is used in the following local debugging example. You can also upload this file directly to use the custom plugin feature in the Cloud-native API Gateway marketplace.
Header state management
|
Header |
Description |
|
|
Indicates that the current filter has completed processing, and the request can be passed to the next filter. |
|
|
Indicates that the headers cannot yet be passed to the next filter. However, this does not stop reading data from the connection, and the body data processing is still triggered. This allows you to update HTTP request headers during the body data processing stage. If the body data needs to be passed to the next filter, the headers are passed along with it. Note
When you return this state, a request body must be present. If there is no body, the request or response is blocked indefinitely. You can use HasRequestBody() to check if a request body exists. |
|
|
Indicates that the header can be passed to the next filter for processing, but the |
|
|
Stops all iterations. This action prevents the header from being passed to the next filter and stops the current filter from receiving body data. The headers, data, and trailers for the current and subsequent filters are buffered. If the buffer size exceeds the buffer limit, a 413 error is returned during the request phase, and a 500 error is returned during the response phase. You must also call the |
|
|
This is the same as |
For use cases of types.HeaderStopIteration and HeaderStopAllIterationAndWatermark, see the official Higress ai-transformer plugin and ai-quota plugin.
To configure this plugin in Higress with the Wasmplugin CRD or the console UI, package the .wasm file into an oci or Docker image. For more information, see Custom plugins.
Perform local debugging
Prepare the required tool
Install Docker.
Start Docker Compose for verification
-
Go to the directory you created for the plugin, such as the wasm-demo directory. Confirm that the main.wasm file exists in this directory.
-
In the directory, create a file named docker-compose.yaml with the following content:
version: '3.7' services: envoy: image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/gateway:v2.1.5 entrypoint: /usr/local/bin/envoy # Note: Debug-level logging is enabled for Wasm here. For production deployments, the default level is info. command: -c /etc/envoy/envoy.yaml --component-log-level wasm:debug depends_on: - httpbin networks: - wasmtest ports: - "10000:10000" volumes: - ./envoy.yaml:/etc/envoy/envoy.yaml - ./main.wasm:/etc/envoy/main.wasm httpbin: image: kennethreitz/httpbin:latest networks: - wasmtest ports: - "12345:80" networks: wasmtest: {} -
In the same directory, create a file named envoy.yaml with the following content:
admin: address: socket_address: protocol: TCP address: 0.0.0.0 port_value: 9901 static_resources: listeners: - name: listener_0 address: socket_address: protocol: TCP address: 0.0.0.0 port_value: 10000 filter_chains: - filters: - name: envoy.filters.network.http_connection_manager typed_config: "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager scheme_header_transformation: scheme_to_overwrite: https stat_prefix: ingress_http route_config: name: local_route virtual_hosts: - name: local_service domains: ["*"] routes: - match: prefix: "/" route: cluster: httpbin http_filters: - name: wasmdemo typed_config: "@type": type.googleapis.com/udpa.type.v1.TypedStruct type_url: type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm value: config: name: wasmdemo vm_config: runtime: envoy.wasm.runtime.v8 code: local: filename: /etc/envoy/main.wasm configuration: "@type": "type.googleapis.com/google.protobuf.StringValue" value: | { "mockEnable": false } - name: envoy.filters.http.router typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router clusters: - name: httpbin connect_timeout: 30s type: LOGICAL_DNS # Comment out the following line to test on v6 networks dns_lookup_family: V4_ONLY lb_policy: ROUND_ROBIN load_assignment: cluster_name: httpbin endpoints: - lb_endpoints: - endpoint: address: socket_address: address: httpbin port_value: 80 -
Run the following command to start Docker Compose.
docker compose up
Feature verification
Verify the plug-in
-
Use
curlto directly access httpbin. The output shows the request headers without passing through the gateway.curl http://127.0.0.1:12345/get { "args": {}, "headers": { "Accept": "*/*", "Host": "127.0.0.1:12345", "User-Agent": "curl/7.79.1" }, "origin": "172.18.0.1", "url": "http://127.0.0.1:12345/get" } -
Use
curlto access httpbin through the gateway. The output shows the request headers after being processed by the gateway.curl http://127.0.0.1:10000/get { "args": {}, "headers": { "Accept": "*/*", "Hello": "world", "Host": "127.0.0.1:10000", "Original-Host": "127.0.0.1:10000", "Req-Start-Time": "1681269273896", "User-Agent": "curl/7.79.1", "X-Envoy-Expected-Rq-Timeout-Ms": "15000" }, "origin": "172.18.0.3", "url": "https://127.0.0.1:10000/get" }
This output confirms the plugin is working, as it added the hello: world request header.
Verify the configuration modification of the plug-in
-
Modify envoy.yaml and set the
mockEnableparameter totrue.configuration: "@type": "type.googleapis.com/google.protobuf.StringValue" value: | { "mockEnable": true } -
Use
curlto access httpbin through the gateway. The output shows the content of the response after it is processed by the gateway.curl http://127.0.0.1:10000/get hello world
This output confirms the plugin configuration change is working, as the gateway now returns the mocked "hello world" response.
More examples
Develop a plug-in with no configurations
If you do not need to configure the plug-in, you can define an empty structure.
package main
import (
"github.com/higress-group/wasm-go/pkg/wrapper"
logs "github.com/higress-group/wasm-go/pkg/log"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
)
func main() {}
func init() {
wrapper.SetCtx(
"hello-world",
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
)
}
type MyConfig struct {}
func onHttpRequestHeaders(ctx wrapper.HttpContext, config MyConfig, log logs.Log) types.Action {
proxywasm.SendHttpResponse(200, nil, []byte("hello world"), -1)
return types.HeaderContinue
}
Use a plug-in to access external services
Currently, only HTTP calls are supported. You can access Nacos and Kubernetes services, as well as services from a fixed address or DNS source, that are configured with a service source in the gateway console. Please note that you cannot directly use the HTTP client from the net/http library. You must use the wrapped HTTP client as shown in the following example.
In the following example, the configuration parsing logic creates an HTTP client based on the service type. During request header processing, this client calls the external service. The plugin then parses a header from the service's response and adds it to the original request.
package main
import (
"errors"
"net/http"
"strings"
"github.com/higress-group/wasm-go/pkg/wrapper"
logs "github.com/higress-group/wasm-go/pkg/log"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
"github.com/tidwall/gjson"
)
func main() {}
func init() {
wrapper.SetCtx(
"http-call",
wrapper.ParseConfigBy(parseConfig),
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
)
}
type MyConfig struct {
// The client used to initiate HTTP calls.
client wrapper.HttpClient
// The request URL path.
requestPath string
// The key used to extract a field from the service's response header.
// The extracted value is then set in the original request header with this key.
tokenHeader string
}
func parseConfig(json gjson.Result, config *MyConfig, log logs.Log) error {
config.tokenHeader = json.Get("tokenHeader").String()
if config.tokenHeader == "" {
return errors.New("missing tokenHeader in config")
}
config.requestPath = json.Get("requestPath").String()
if config.requestPath == "" {
return errors.New("missing requestPath in config")
}
// The full FQDN with a service type suffix, such as my-svc.dns, my-svc.static,
// service-provider.DEFAULT-GROUP.public.nacos, or httpbin.my-ns.svc.cluster.local.
serviceName := json.Get("serviceName").String()
servicePort := json.Get("servicePort").Int()
if servicePort == 0 {
if strings.HasSuffix(serviceName, ".static") {
// The logical port for static IP services is 80.
servicePort = 80
}
}
config.client = wrapper.NewClusterClient(wrapper.FQDNCluster{
FQDN: serviceName,
Port: servicePort,
})
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, config MyConfig, log logs.Log) types.Action {
// Use the client's Get method to initiate an HTTP GET call. The timeout parameter is omitted,
// so the default timeout of 500 ms is used.
err := config.client.Get(config.requestPath, nil,
// Callback function. It is executed when the response is returned asynchronously.
func(statusCode int, responseHeaders http.Header, responseBody []byte) {
// Handle the case where the request does not return a 200 status code.
if statusCode != http.StatusOK {
log.Errorf("http call failed, status: %d", statusCode)
proxywasm.SendHttpResponse(http.StatusInternalServerError, nil,
[]byte("http call failed"), -1)
return
}
// Log the HTTP status code and response body.
log.Infof("get status: %d, response body: %s", statusCode, responseBody)
// Parse the token field from the response header and set it in the original request header.
token := responseHeaders.Get(config.tokenHeader)
if token != "" {
proxywasm.AddHttpRequestHeader(config.tokenHeader, token)
}
// Resume the original request flow to forward it to the backend service.
proxywasm.ResumeHttpRequest()
})
if err != nil {
// If the external service call fails, allow the request to proceed and log the error.
log.Errorf("Error occurred while calling http, it seems the service cluster cannot be found.")
return types.ActionContinue
} else {
// Wait for the async callback to complete. Return HeaderStopAllIterationAndWatermark,
// which can be resumed by ResumeHttpRequest.
return types.HeaderStopAllIterationAndWatermark
}
}
Use a plug-in to call ApsaraDB for Redis services
Use the following example code to implement a Redis-based throttling plugin.
package main
import (
"strconv"
"time"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
"github.com/tidwall/gjson"
"github.com/tidwall/resp"
"github.com/higress-group/wasm-go/pkg/wrapper"
logs "github.com/higress-group/wasm-go/pkg/log"
)
func main() {}
func init() {
wrapper.SetCtx(
"redis-demo",
wrapper.ParseConfigBy(parseConfig),
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
wrapper.ProcessResponseHeadersBy(onHttpResponseHeaders),
)
}
type RedisCallConfig struct {
client wrapper.RedisClient
qpm int
}
func parseConfig(json gjson.Result, config *RedisCallConfig, log logs.Log) error {
// The full FQDN with a service type suffix, such as my-redis.dns or redis.my-ns.svc.cluster.local.
serviceName := json.Get("serviceName").String()
servicePort := json.Get("servicePort").Int()
if servicePort == 0 {
if strings.HasSuffix(serviceName, ".static") {
// The logical port for static IP services is 80.
servicePort = 80
} else {
servicePort = 6379
}
}
username := json.Get("username").String()
password := json.Get("password").String()
// Unit: milliseconds.
timeout := json.Get("timeout").Int()
if timeout == 0 {
timeout = 1000
}
qpm := json.Get("qpm").Int()
config.qpm = int(qpm)
config.client = wrapper.NewRedisClusterClient(wrapper.FQDNCluster{
FQDN: serviceName,
Port: servicePort,
})
return config.client.Init(username, password, timeout)
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, config RedisCallConfig, log logs.Log) types.Action {
now := time.Now()
minuteAligned := now.Truncate(time.Minute)
timeStamp := strconv.FormatInt(minuteAligned.Unix(), 10)
// If the Redis API returns err != nil, the gateway usually cannot find the Redis backend service.
// Check whether the Redis backend service has been deleted.
err := config.client.Incr(timeStamp, func(response resp.Value) {
if response.Error() != nil {
log.Errorf("call redis error: %v", response.Error())
proxywasm.ResumeHttpRequest()
} else {
ctx.SetContext("timeStamp", timeStamp)
ctx.SetContext("callTimeLeft", strconv.Itoa(config.qpm-response.Integer()))
if response.Integer() == 1 {
err := config.client.Expire(timeStamp, 60, func(response resp.Value) {
if response.Error() != nil {
log.Errorf("call redis error: %v", response.Error())
}
proxywasm.ResumeHttpRequest()
})
if err != nil {
log.Errorf("Error occured while calling redis, it seems cannot find the redis cluster.")
proxywasm.ResumeHttpRequest()
}
} else {
if response.Integer() > config.qpm {
proxywasm.SendHttpResponse(429, [][2]string{{"timeStamp", timeStamp}, {"callTimeLeft", "0"}}, []byte("Too many requests\n"), -1)
} else {
proxywasm.ResumeHttpRequest()
}
}
}
})
if err != nil {
// If the Redis call fails, allow the request to proceed and log the error.
log.Errorf("Error occured while calling redis, it seems cannot find the redis cluster.")
return types.HeaderContinue
} else {
// Hold the request until the Redis call completes.
return types.HeaderStopAllIterationAndWatermark
}
}
func onHttpResponseHeaders(ctx wrapper.HttpContext, config RedisCallConfig, log logs.Log) types.Action {
if ctx.GetContext("timeStamp") != nil {
proxywasm.AddHttpResponseHeader("timeStamp", ctx.GetContext("timeStamp").(string))
}
if ctx.GetContext("callTimeLeft") != nil {
proxywasm.AddHttpResponseHeader("callTimeLeft", ctx.GetContext("callTimeLeft").(string))
}
return types.HeaderContinue
}