Developing gateway plugins with Go

更新时间:
复制 MD 格式

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.

Important

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.

Note

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 Files or Program Files (x86) directory.

  • After the installation is successful, press Win+R to open the Run window. In the Run window, enter cmd and click OK to open the command prompt. Enter the go version command. 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/go directory.

  • Open a terminal and run the go version command. 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.gz
    • Configure the environment variable.

      export PATH=$PATH:/usr/local/go/bin
    • Run go version. The command outputs the currently installed version, which indicates that the installation was successful.

Develop the plugin

Initialize the project directory

  1. Create a project directory, such as wasm-demo-go.

  2. In the directory, run the following command to initialize the Go module.

    go mod init wasm-demo-go
  3. 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
  4. 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.

Note
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.

wrapper.ProcessRequestHeadersBy

HTTP request body processing

The gateway receives request body data from the client.

wrapper.ProcessRequestBodyBy

HTTP response header processing

The gateway receives response header data from the backend service.

wrapper.ProcessResponseHeadersBy

HTTP response body processing

The gateway receives response body data from the backend service.

wrapper.ProcessResponseBodyBy

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 DispatchHttpCall request.

-

GetHttpCallResponseBody

Gets the response body of a DispatchHttpCall request.

-

GetHttpCallResponseTrailers

Gets the response trailers of a DispatchHttpCall request.

-

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.

-

Important

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

HeaderContinue

Indicates that the current filter has completed processing, and the request can be passed to the next filter. types.ActionContinue corresponds to this state.

HeaderStopIteration

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.

HeaderContinueAndEndStream

Indicates that the header can be passed to the next filter for processing, but the end_stream = false flag signals that the request is not yet complete, allowing the current filter to add a body.

HeaderStopAllIterationAndBuffer

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 proxywasm.ResumeHttpRequest(), proxywasm.ResumeHttpResponse(), or proxywasm.SendHttpResponseWithDetail() function to resume subsequent processing.

HeaderStopAllIterationAndWatermark

This is the same as HeaderStopAllIterationAndBuffer, except that flow control is triggered when the buffer exceeds the buffer limit. This pauses data reads from the connection. The types.ActionPause in the 0.2.1 ABI corresponds to this state.

Note

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

  1. 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.

  2. 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: {}
  3. 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
  4. Run the following command to start Docker Compose.

    docker compose up

Feature verification

Verify the plug-in

  1. Use curl to 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"
    }
  2. Use curl to 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

  1. Modify envoy.yaml and set the mockEnable parameter to true.

      configuration:
        "@type": "type.googleapis.com/google.protobuf.StringValue"
        value: |
          {
            "mockEnable": true
          }
  2. Use curl to 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
}