All Products
Search
Document Center

API Gateway:Develop gateway plugins using Go

Last Updated:Oct 16, 2025

You can develop gateway plugins to extend the core features of AI Gateway and meet complex business needs. This topic describes how to develop gateway plugins using Go and provides guidance for local development and debugging.

Important

Higress has migrated from the TinyGo 0.29 and Go 1.20 compilation solution to the native WASM compilation supported by Go 1.24. Go 1.24 natively supports WASM file compilation.

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. You also need to adjust the dependencies in the go.mod file. For a specific example, see the content later in this topic.

For plugins originally implemented with TinyGo, note the following adaptation points:

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 reference implementation, see the example of calling an external service from a plugin later in this topic.

2. If you used the go-re2 library because TinyGo's support for the standard regexp library was incomplete, you must replace it with the official Go regexp package.

Prerequisites

Install Go.

Golang

Official guide (version 1.24 or later).

Note

Plugins compiled with Go 1.24 require AI Gateway v2.1.5 or later. For older gateway versions, see Develop WASM plugins using Go.

Windows

  • Download the installation file.

  • Open the downloaded installation file to install Go. By default, Go is installed in the Program Files or Program Files (x86) directory.

  • After the installation is complete, press the Win+R keyboard shortcut to open the Run window. Enter cmd and click OK to open the command prompt. Run the go version command. If the command returns the installed version number, the installation is successful.

macOS

  • Download the installation file.

  • Double-click the downloaded installation file to install Go. By default, Go is installed in the /usr/local/go directory.

  • Open the Terminal and run the go version command. If the command returns the installed version number, the installation is successful.

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.gz
    • Configure the environment variable.

      export PATH=$PATH:/usr/local/go/bin
    • Run the go version command. If the command returns the installed version number, the installation is successful.

Develop a plugin

Initialize the project directory

  1. Create a project directory. For example: wasm-demo-go.

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

    go mod init wasm-demo-go
  3. If you are in mainland China, 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 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

Develop the main.go file

The following example shows how to return hello world when mockEnable: true is configured for the plugin. If the plugin is not configured or mockEnable: false is set, the plugin adds a hello: world request header to the original request.

Note
The plugin configuration in the gateway console is in YAML format. The configuration is automatically converted to JSON format before it is sent to the plugin. Therefore, the parseConfig function in the example can directly parse the configuration from the 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",
    // A custom function to parse the plugin configuration
     wrapper.ParseConfigBy(parseConfig),
    // A custom function to process request headers
    wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
  )
}

// Custom plugin configuration
type MyConfig struct {
  mockEnable bool
}

// The YAML configuration file 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 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 example code above, the custom function onHttpRequestHeaders is used for processing HTTP request headers via wrapper.ProcessRequestHeadersBy. In addition, you can set custom handler functions for other stages as follows.

HTTP processing phase

Trigger condition

Mount method

Request header processing

When the gateway receives request headers from the client

wrapper.ProcessRequestHeadersBy

Request body processing

When the gateway receives the request body from the client

wrapper.ProcessRequestBodyBy

Response header processing

When the gateway receives response headers from the backend service

wrapper.ProcessResponseHeadersBy

Response body processing

When the gateway receives the response body from the backend service

wrapper.ProcessResponseBodyBy

Methods

The proxywasm.AddHttpRequestHeader and proxywasm.SendHttpResponse methods in the preceding example are two methods provided by the plugin SDK. The main methods are listed in the following table.

Category

Method name

Purpose

HTTP processing phase

Request header processing

GetHttpRequestHeaders

Get all request headers from the client

Request header processing

ReplaceHttpRequestHeaders

Replace all request headers from the client

Request header processing

GetHttpRequestHeader

Get a specific request header from the client

Request header processing

RemoveHttpRequestHeader

Remove a specific request header from the client

Request header processing

ReplaceHttpRequestHeader

Replace a specific request header from the client

Request header processing

AddHttpRequestHeader

Add a request header to the client request

Request header processing

Request body processing

GetHttpRequestBody

Get the client request body

Request body processing

AppendHttpRequestBody

Append a specified byte string to the end of the client request body

Request body processing

PrependHttpRequestBody

Append a specified byte string to the beginning of the client request body

Request body processing

ReplaceHttpRequestBody

Replace the client request body

Request body processing

Response header processing

GetHttpResponseHeaders

Get all response headers from the backend

Response header processing

ReplaceHttpResponseHeaders

Replace all response headers from the backend

Response header processing

GetHttpResponseHeader

Get a specific response header from the backend

Response header processing

RemoveHttpResponseHeader

Remove a specific response header from the backend

Response header processing

ReplaceHttpResponseHeader

Replace a specific response header from the backend

Response header processing

AddHttpResponseHeader

Add a response header to the backend response

Response header processing

Response body processing

GetHttpResponseBody

Get the client request body

Response body processing

AppendHttpResponseBody

Append a specified byte string to the end of the backend response body

Response body processing

PrependHttpResponseBody

Append a specified byte string to the beginning of the backend response body

Response body processing

ReplaceHttpResponseBody

Replace the backend response body

Response body processing

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

Return a specific HTTP response directly

-

Flow resumption

ResumeHttpRequest

Resume a previously paused request processing flow

-

ResumeHttpResponse

Resume a previously paused response processing flow

-

Warning

Please do not call ResumeHttpRequest or ResumeHttpResponse when the request or response is not in the Paused state. In particular, note that after SendHttpResponse is called, any paused requests or responses will automatically resume. Calling ResumeHttpRequest or ResumeHttpResponse again in this case may result in undefined behavior.

Compile the WASM file

Compile the wasm file

If you use a custom-initialized directory, run the following commands to compile the wasm file.

go mod tidy
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o main.wasm ./

After the compilation, a file named main.wasm is generated. This file is used in the local debugging example that is described later in this topic. You must upload this file when you use the custom plugin feature in the cloud-native gateway marketplace.

Header status management

Header

Description

HeaderContinue

Indicates that the current filter has finished processing and the request can be passed to the next filter. This corresponds to types.ActionContinue.

HeaderStopIteration

Indicates that the header cannot yet be passed to the next filter for processing. However, data reading from the connection does not stop, and processing of the body data continues. This allows for the possibility of updating the HTTP request headers during the body data handling phase. If the body data needs to be passed to the next filter, the header will be delivered along with the body data to the next filter at that time.

Note

Note: When this status is returned, a body is required. If there is no body, the request or response will be blocked.

To check if a request body exists, use HasRequestBody().

HeaderContinueAndEndStream

Indicates that the header can be passed to the next filter, but the next filter receives end_stream = false to mark that the request is not yet complete. This allows the current filter to add more to the body.

HeaderStopAllIterationAndBuffer

Stops all iterations. This indicates that the header cannot be passed to the next filter, and the current filter cannot receive body data. It buffers the headers, data, and trailers for the current and subsequent filters. If the buffer size exceeds the buffer limit, a 413 status code is returned for requests, and a 500 status code is returned for responses. You must call proxywasm.ResumeHttpRequest(), proxywasm.ResumeHttpResponse(), or proxywasm.SendHttpResponseWithDetail() to resume subsequent processing.

HeaderStopAllIterationAndWatermark

This is similar to HeaderStopAllIterationAndBuffer, but it triggers throttling when the buffer exceeds the buffer limit. Throttling pauses data reading from the connection. The types.ActionPause in ABI 0.2.1 corresponds to this status.

Note

Note: For more information about 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

Install Docker Engine.

Use Docker Compose to launch and verify

  1. Go to the project directory (take wasm-demo directory as an example). Make sure that the main.wasm file is generated 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: The log level for wasm is set to debug here. For production deployments, the default 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

Verify the feature

Verify the WASM feature

  1. Use curl to access httpbin. The following code show 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 following code show the request headers after they are 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"
    }

The plugin feature is active, and the hello: world request header is added.

Verify the plugin configuration change

  1. Modify envoy.yaml. Set mockEnable to true.

      configuration:
        "@type": "type.googleapis.com/google.protobuf.StringValue"
        value: |
          {
            "mockEnable": true
          }
  2. Use curl to access httpbin through the gateway. The following code shows the response after it is processed by the gateway.

    curl http://127.0.0.1:10000/get
    
    hello world

This indicates that the plugin configuration change has taken effect. The mock response feature is enabled, and "hello world" is returned.

More examples

Plugin with no configuration

If a plugin requires no configuration, just 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 external services in a plugin

Currently, only HTTP calls are supported. You can access services registered in Nacos or Kubernetes (K8s) that have their service source configured in the gateway console, as well as services with a fixed address or DNS source. Please note that you cannot use the HTTP client from the net/http library directly; you must use the HTTP client encapsulated as shown in the example below.

In the example, the service type is determined and the corresponding HTTP client is created during the configuration parsing phase. During the request header processing phase, the configured request path is used to access the corresponding service, the response headers are parsed, and then set back into 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
  // The key to retrieve the corresponding field from the called service's response headers. This field is then set in the original request's headers.
  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, for example, 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, so the default timeout is 500 milliseconds.
  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. Handle the error.
             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 headers and set it in the original request headers.
             token := responseHeaders.Get(config.tokenHeader)
             if token != "" {
               proxywasm.AddHttpRequestHeader(config.tokenHeader, token)
             }
             // Resume the original request flow to continue processing and forward it to the backend service.
             proxywasm.ResumeHttpRequest()
    })

  if err != nil {
    // The external service call failed. Continue the request and log the error.
    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 HeaderStopAllIterationAndWatermark, which can be resumed by ResumeHttpRequest.
    return types.HeaderStopAllIterationAndWatermark
  }
}

Call Redis in a plugin

Use the following code example 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, for example, 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, 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 {
    // The Redis call failed. Continue the request 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 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
}