You can extend the core features of an API gateway by developing gateway plugins to meet complex and specific business requirements. This topic describes how to develop gateway plugins using Go and provides guidance for local development and debugging.
Higress has migrated from the TinyGo 0.29 and Go 1.20 compilation solution to the native WebAssembly (Wasm) compilation supported by Go 1.24. Go 1.24 now provides native support for Wasm file compilation.
If you previously used TinyGo to compile plugins and want to migrate to the Go 1.24 compilation mode, you must adjust the dependencies in `go.mod`. You also need to move the plugin initialization logic from the `main` function to the `init` function. For a specific example, see the information provided later in this topic.
For plugins that were originally implemented with TinyGo, note the following compatibility adjustments:
1. If you call an external service during the header processing phase and return `type.ActionPause`, you must change it to return `types.HeaderStopAllIterationAndWatermark`. For a related implementation, see the example of a plugin that calls an external service later in this topic.
2. If you used the `go-re2` library because TinyGo has incomplete support for the standard `regexp` library, you must replace it with the official Go `regexp` package.
Prerequisites
You must install Go.
Golang
For more information, see the official installation guide. Version 1.24 or later is required.
Plugins compiled with Go 1.24 require MSE Gateway version 2.0.11 or later. For earlier gateway versions, see Develop WASM plugins using Go.
Windows
Download the installation file.
Double-click the downloaded installation file to start the installation. By default, Go is installed in the
Program FilesorProgram Files (x86)folder.After the installation is complete, press Win+R to open the Run window. Enter
cmdand click OK to open the command prompt. Run thego versioncommand. If the command successfully outputs the current version, the installation is complete.
macOS
Download the installation file.
Double-click the downloaded installation file to start the installation. By default, Go is installed in the
/usr/local/godirectory.Open the terminal and run the
go versioncommand. If the command successfully outputs the current version, the installation is complete.
Linux
Download the installation file.
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. If the command successfully outputs the current version, the installation is complete.
Write 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 project.
go mod init wasm-demo-goIf you are in mainland China, you may need to set a proxy for downloading dependencies.
go env -w GOPROXY=https://proxy.golang.com.cn,directDownload the dependencies for building 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 example shows how to return a hello world response directly when mockEnable: true is set in the plugin configuration. If the plugin is not configured or if mockEnable: false is set, the example adds a hello: world header to the original request. For more examples, see the More examples section in this topic.
The plugin configuration in the gateway console is in YAML format. It is automatically converted to JSON format before it is sent to the plugin. Therefore, the `parseConfig` function in the example can parse the configuration directly from JSON.
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 user-defined function to parse the plugin configuration
wrapper.ParseConfigBy(parseConfig),
// Set a user-defined 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. You can parse the configuration directly from the json parameter.
func parseConfig(json gjson.Result, config *MyConfig, log logs.Log) error {
// Parse the configuration and update it in the config.
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, you use the wrapper.ProcessRequestHeadersBy method to apply the user-defined function onHttpRequestHeaders to the HTTP request header processing phase. You can also use the following methods to set user-defined functions for other phases.
HTTP processing phase | Trigger condition | Mount method |
HTTP request header processing phase | When the gateway receives request header data from the client |
|
HTTP request body processing phase | When the gateway receives request body data from the client |
|
HTTP response header processing phase | When the gateway receives response header data from the backend service |
|
HTTP response body processing phase | When the gateway receives response body data from the backend service |
|
Utility methods
The proxywasm.AddHttpRequestHeader and proxywasm.SendHttpResponse methods in the preceding example are two utility methods provided by the plugin SDK. The main utility methods are listed in the following table.
Category | Method name | Purpose | Effective HTTP processing phase |
Request header processing | GetHttpRequestHeaders | Get all request headers from the client | HTTP request header processing phase |
ReplaceHttpRequestHeaders | Replace all request headers from the client | HTTP request header processing phase | |
GetHttpRequestHeader | Get a specific request header from the client | HTTP request header processing phase | |
RemoveHttpRequestHeader | Remove a specific request header from the client | HTTP request header processing phase | |
ReplaceHttpRequestHeader | Replace a specific request header from the client | HTTP request header processing phase | |
AddHttpRequestHeader | Add a new client request header | HTTP request header processing phase | |
Request body processing | GetHttpRequestBody | Get the client request body | HTTP request body processing phase |
AppendHttpRequestBody | Append a specified byte string to the end of the client request body | HTTP request body processing phase | |
PrependHttpRequestBody | Prepend a specified byte string to the beginning of the client request body | HTTP request body processing phase | |
ReplaceHttpRequestBody | Replace the client request body | HTTP request body processing phase | |
Response header processing | GetHttpResponseHeaders | Get all response headers from the backend | HTTP response header processing phase |
ReplaceHttpResponseHeaders | Replace all response headers from the backend | HTTP response header processing phase | |
GetHttpResponseHeader | Get a specific response header from the backend | HTTP response header processing phase | |
RemoveHttpResponseHeader | Remove a specific response header from the backend | HTTP response header processing phase | |
ReplaceHttpResponseHeader | Replace a specific response header from the backend | HTTP response header processing phase | |
AddHttpResponseHeader | Add a new backend response header | HTTP response header processing phase | |
Response body processing | GetHttpResponseBody | You can obtain the client request body. | HTTP response body processing phase |
AppendHttpResponseBody | Append a specified byte string to the end of the backend response body | HTTP response body processing phase | |
PrependHttpResponseBody | Prepend a specified byte string to the beginning of the backend response body | HTTP response body processing phase | |
ReplaceHttpResponseBody | Replace the backend response body | HTTP response body processing phase | |
HTTP call | DispatchHttpCall | Send an HTTP request | - |
GetHttpCallResponseHeaders | Get the response headers of a `DispatchHttpCall` request | - | |
GetHttpCallResponseBody | Get the response body of a `DispatchHttpCall` request | - | |
GetHttpCallResponseTrailers | Get the response trailers of a `DispatchHttpCall` request | - | |
Direct response | SendHttpResponse | Directly return a specific HTTP response | - |
Flow resumption | ResumeHttpRequest | Resume a previously paused request processing flow | - |
ResumeHttpResponse | Resume a previously paused response processing flow | - |
Do not call `ResumeHttpRequest` or `ResumeHttpResponse` when the request or response is not in a paused state. Note that after `SendHttpResponse` is called, a paused request or response is automatically resumed. Calling `ResumeHttpRequest` or `ResumeHttpResponse` again results in undefined behavior.
Compile and generate the WASM file
Compile the wasm file locally
If you use a custom initialized directory, run the following command to compile the Wasm file.
go mod tidy
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o main.wasm ./After a successful compilation, a new file named `main.wasm` is generated. This file is used in the local debugging example later in this topic. To use the custom plugin feature in the cloud-native gateway marketplace, you must upload this file.
Header status management
Header | Description |
| Indicates that the current filter has finished processing and the request can be passed to the next filter. |
| Indicates that the header cannot be passed to the next filter yet. However, data reading from the connection is not stopped, and body data processing continues to be triggered. This lets you update the HTTP request headers during the body data processing phase. If the body data needs to be passed to the next filter, the header is also passed along with it. Note When this status is returned, a body is required. If there is no body, the request or response will be blocked indefinitely. To check if a request body exists, use HasRequestBody(). |
| Indicates that the header can be passed to the next filter, but the next filter receives |
| Stops all iterations. This indicates that the header cannot be passed to the next filter, and the current filter cannot receive body data. The headers, data, and trailers for the current and subsequent filters are buffered. If the buffer size exceeds the limit, a 413 status code is returned during the request phase, and a 500 status code is returned during the response phase. You must also call |
| This is the same as |
For scenarios that involve `types.HeaderStopIteration` and `HeaderStopAllIterationAndWatermark`, see the official Higress ai-transformer plugin and ai-quota plugin.
To configure this plugin in Higress using the WasmPlugin CustomResourceDefinition (CRD) or the console UI, you must package the Wasm file into an OCI or Docker image. For more information, see Custom plugins.
Local debugging
Prerequisites
You must install Docker.
Start and verify using Docker Compose
Go to the directory that you created when you wrote the plugin, such as the `wasm-demo` directory. Confirm that the `main.wasm` file has been compiled and generated 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 that 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: 80Run the following command to start Docker Compose.
docker compose up
Verify the features
Verify the WASM feature
Use curl to directly access httpbin. You can view the request headers when the request does not pass through the gateway, as shown in the following example.
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 curl to access httpbin through the gateway. You can view the request headers after they are processed by the gateway, as shown in the following example.
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" }
The plugin feature is now active, and the `hello: world` request header has been added.
Verify the plugin configuration modification
Modify `envoy.yaml` and set
mockEnabletotrue.configuration: "@type": "type.googleapis.com/google.protobuf.StringValue" value: | { "mockEnable": true }Use curl to access httpbin through the gateway. You can view the response after it is processed by the gateway, as shown in the following example.
curl http://127.0.0.1:10000/get hello world
This indicates that the plugin configuration modification is effective. The mock response is enabled, and `hello world` is returned directly.
More examples
Plugin without configuration
If a plugin does not require configuration, you can simply define an empty struct.
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
}Request an external service from a plugin
Currently, only HTTP calls are supported. You can access Nacos services, Kubernetes services, and services from fixed IP addresses or DNS sources that are configured in the gateway console. Note that you cannot directly use the HTTP client from the net/http library. You must use the encapsulated HTTP client shown in the following example.
In the following example, the service type is parsed during the configuration parsing phase to generate the corresponding HTTP client. During the request header processing phase, the corresponding service is called based on the configured request path. The response headers are then parsed and set in the original request headers.
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
// Request URL
requestPath string
// Use this key to retrieve the corresponding field from the response header of the called service, and then set it in the original request header. The key is this configuration item.
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 the service type, 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 here, and the default timeout is 500 ms.
err := config.client.Get(config.requestPath, nil,
// The callback function, which is executed when the response is returned asynchronously
func(statusCode int, responseHeaders http.Header, responseBody []byte) {
// The request did not return a 200 status code. Process it.
if statusCode != http.StatusOK {
log.Errorf("http call failed, status: %d", statusCode)
proxywasm.SendHttpResponse(http.StatusInternalServerError, nil,
[]byte("http call failed"), -1)
return
}
// Print 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 continue processing so that it can be forwarded to the backend service
proxywasm.ResumeHttpRequest()
})
if err != nil {
// Because the call to the external service failed, allow the request to proceed and log the event.
log.Errorf("Error occured while calling http, it seems cannot find the service cluster.")
return types.ActionContinue
} else {
// Wait for the asynchronous callback to complete. Return the HeaderStopAllIterationAndWatermark status, which can be resumed by ResumeHttpRequest.
return types.HeaderStopAllIterationAndWatermark
}
}Call Redis from a plugin
Use the following example code to implement a Redis rate-limiting 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 the service type, 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: ms
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, it is usually because the gateway cannot find the Redis backend service. Check if the Redis backend service was accidentally 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 {
// Because the call to Redis failed, allow the request to proceed and log the event.
log.Errorf("Error occured while calling redis, it seems cannot find the redis cluster.")
return types.HeaderContinue
} else {
// Hold the request and wait for the Redis call to complete.
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
}