All Products
Search
Document Center

Microservices Engine:Develop gateway plugins using Go

Last Updated:Oct 16, 2025

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.

Important

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.

Note

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

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

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

      export PATH=$PATH:/usr/local/go/bin
    • Run go version. If the command successfully outputs the current version, the installation is complete.

Write 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 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

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.

Note
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

wrapper.ProcessRequestHeadersBy

HTTP request body processing phase

When the gateway receives request body data from the client

wrapper.ProcessRequestBodyBy

HTTP response header processing phase

When the gateway receives response header data from the backend service

wrapper.ProcessResponseHeadersBy

HTTP response body processing phase

When the gateway receives response body data from the backend service

wrapper.ProcessResponseBodyBy

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

-

Important

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

HeaderContinue

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

HeaderStopIteration

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().

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 body data.

HeaderStopAllIterationAndBuffer

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

HeaderStopAllIterationAndWatermark

This is the same as HeaderStopAllIterationAndBuffer, except that when the buffer exceeds the limit, it triggers throttling, which pauses data reading from the connection. types.ActionPause in ABI 0.2.1 corresponds to this status.

Note

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

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

  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 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: {}
  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 features

Verify the WASM feature

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

  1. Modify `envoy.yaml` and set mockEnable to true.

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