feat: mock API server for testing server SDKs (#4627)

* feat: mock API server for testing server SDKs

* fixed lint
This commit is contained in:
David Zhao
2026-06-28 09:28:18 +02:00
committed by GitHub
parent a81f06b960
commit bfb75d1fc3
6 changed files with 657 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
# Copyright 2026 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Builds and publishes the SDK conformance test server (cmd/test-server) to
# Docker Hub as livekit/test-server:latest on every push to master. The server
# SDK repos boot this image in their CI to run region-failover tests.
name: Release Test Server to Docker
permissions:
contents: read
on:
workflow_dispatch:
push:
branches:
- master
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Login to DockerHub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
with:
context: .
file: cmd/test-server/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
# Each build overrides the latest tag so SDK CI always boots the
# current mock server.
tags: livekit/test-server:latest
+40
View File
@@ -0,0 +1,40 @@
# Copyright 2026 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Mock LiveKit API server for SDK conformance testing. Build from the repo
# root: docker build -f cmd/test-server/Dockerfile -t livekit/test-server .
FROM golang:1.26-alpine AS builder
ARG TARGETARCH
WORKDIR /workspace
COPY go.mod go.mod
COPY go.sum go.sum
RUN go mod download
COPY cmd/ cmd/
COPY pkg/ pkg/
COPY version/ version/
RUN CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH GO111MODULE=on go build -a -o livekit-test-server ./cmd/test-server
FROM alpine
COPY --from=builder /workspace/livekit-test-server /livekit-test-server
# region-0 (primary, 9999) + 3 fallback regions
EXPOSE 9999 10000 10001 10002
ENTRYPOINT ["/livekit-test-server"]
+84
View File
@@ -0,0 +1,84 @@
# LiveKit SDK test server
A stateless, per-request programmable mock of the LiveKit server HTTP API. It
exists so the server SDKs (Go, Rust, Python, Node, Kotlin, Ruby) can exercise
client-side behavior against one shared implementation, published as a Docker
image and booted by each SDK's CI.
## Why it looks the way it does
- **Stateless.** All behavior is selected by per-request `X-Lk-Mock-*` headers,
so the server holds no mutable state and tests run in parallel.
- **Multi-port = multi-region.** The process binds one listener per simulated
region (`--ports`). A port's position in the list is its **region index**;
index `0` is the primary the SDK is initially pointed at. `GET
/settings/regions` advertises all of them in order.
- **One header drives every attempt.** The SDK sends the same control header on
the initial request *and* every failover retry. Each listener decides what to
do from its **own** index, so a single `X-Lk-Mock-Fail-Regions: 0` makes the
primary fail while the first fallback succeeds — no coordination needed.
- **The whole API is mocked with populated responses.** Every RoomService,
Egress, Ingress, SIP, and Connector method returns a type-correct, populated
response: scalar fields that share a name with the request are echoed (e.g.
`name`, `metadata`, `identity`, timeouts), `id`/`sid` fields get placeholder
values, and list endpoints return one element. Both protobuf and JSON Twirp
clients are supported. A client can override the response entirely with the
`X-Lk-Mock-Response` header (see below). Unregistered/future methods fall back
to an empty (all-default) message, which still decodes cleanly.
## Running
```bash
go run ./cmd/test-server # primary :9999, regions :10000-10002
go run ./cmd/test-server --ports 9999,10000 # primary + one fallback
# Docker
docker build -f cmd/test-server/Dockerfile -t livekit/test-server .
docker run -p 9999-10002:9999-10002 livekit/test-server
```
| Flag | Env | Default | Meaning |
|---|---|---|---|
| `--ports` | `LK_TEST_SERVER_PORTS` | `9999,10000,10001,10002` | listener ports; index = position |
| `--advertise-host` | `LK_TEST_SERVER_ADVERTISE_HOST` | `http://127.0.0.1` | base URL used in `/settings/regions` |
| `--bind` | `LK_TEST_SERVER_BIND` | `0.0.0.0` | bind address |
| `--twirp-prefix` | `LK_TEST_SERVER_TWIRP_PREFIX` | `/twirp` | Twirp path prefix |
## Control protocol
Request headers (sent by the SDK on API calls; the SDK must forward
client-configured custom headers onto the `/settings/regions` fetch and every
failover retry):
| Header | Default | Effect |
|---|---|---|
| `X-Lk-Mock-Fail-Regions` | — | comma list of region indices that fail this request, e.g. `0` or `0,1`. Each listener fails only if its own index is listed. |
| `X-Lk-Mock-Fail-Mode` | `status` | how a failing region fails: `status`, `drop` (close connection → transport error), `delay`. |
| `X-Lk-Mock-Fail-Status` | `503` | HTTP status when failing with `status`/`delay`. |
| `X-Lk-Mock-Fail-Twirp-Code` | derived from status | Twirp error code string in the failure body. |
| `X-Lk-Mock-Delay-Ms` | `30000` | delay before a `delay`-mode region responds (for timeout tests). |
| `X-Lk-Mock-Regions-Status` | `200` | override the status of `GET /settings/regions`. |
| `X-Lk-Mock-Response` | — | protojson of the response message for the called method; replaces the populated default, giving full control over the returned payload. |
Response headers:
| Header | Meaning |
|---|---|
| `X-Lk-Mock-Region` | index of the region that served the response (blank on a failed region). Assert on this to confirm which region a failover landed on. |
## Common recipes
| Goal | Headers |
|---|---|
| Happy path | _(none)_ → 200 from region `0` |
| Failover succeeds on region 1 | `X-Lk-Mock-Fail-Regions: 0` |
| Exhaust to region 2 | `X-Lk-Mock-Fail-Regions: 0,1` |
| All regions down | `X-Lk-Mock-Fail-Regions: 0,1,2,3` |
| 4xx, no retry | `X-Lk-Mock-Fail-Regions: 0` + `X-Lk-Mock-Fail-Status: 400` |
| Transport-error failover | `X-Lk-Mock-Fail-Regions: 0` + `X-Lk-Mock-Fail-Mode: drop` |
| Region discovery unreachable | `X-Lk-Mock-Regions-Status: 500` |
| Custom response payload | `X-Lk-Mock-Response: {"sid":"RM_x","name":"my-room"}` |
Note: SDK region failover normally only engages for `*.livekit.cloud` hosts.
Since tests point at `127.0.0.1`, set the SDK's failover-enable option to its
forced-on value so failover engages against localhost.
+228
View File
@@ -0,0 +1,228 @@
// Copyright 2026 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
"io"
"net/http"
"strconv"
"strings"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/types/known/emptypb"
"github.com/livekit/protocol/livekit"
"github.com/livekit/protocol/utils/protojson"
)
// apiSpec captures the request and response message types for one Twirp method,
// so the mock can decode the incoming request and build a typed response.
type apiSpec struct {
newReq func() proto.Message
newResp func() proto.Message
}
// ptrMsg constrains a pointer type that is also a proto.Message, letting reg
// construct fresh request/response values generically.
type ptrMsg[T any] interface {
*T
proto.Message
}
// apiHandlers maps "<package>.<Service>/<Method>" to its message types. It
// covers the full LiveKit API surface; see init below.
var apiHandlers = map[string]apiSpec{}
func reg[ReqT, RespT any, Req ptrMsg[ReqT], Resp ptrMsg[RespT]](key string) {
apiHandlers[key] = apiSpec{
newReq: func() proto.Message { return Req(new(ReqT)) },
newResp: func() proto.Message { return Resp(new(RespT)) },
}
}
func init() {
// RoomService
reg[livekit.CreateRoomRequest, livekit.Room]("livekit.RoomService/CreateRoom")
reg[livekit.ListRoomsRequest, livekit.ListRoomsResponse]("livekit.RoomService/ListRooms")
reg[livekit.DeleteRoomRequest, livekit.DeleteRoomResponse]("livekit.RoomService/DeleteRoom")
reg[livekit.ListParticipantsRequest, livekit.ListParticipantsResponse]("livekit.RoomService/ListParticipants")
reg[livekit.RoomParticipantIdentity, livekit.ParticipantInfo]("livekit.RoomService/GetParticipant")
reg[livekit.RoomParticipantIdentity, livekit.RemoveParticipantResponse]("livekit.RoomService/RemoveParticipant")
reg[livekit.MuteRoomTrackRequest, livekit.MuteRoomTrackResponse]("livekit.RoomService/MutePublishedTrack")
reg[livekit.UpdateParticipantRequest, livekit.ParticipantInfo]("livekit.RoomService/UpdateParticipant")
reg[livekit.UpdateSubscriptionsRequest, livekit.UpdateSubscriptionsResponse]("livekit.RoomService/UpdateSubscriptions")
reg[livekit.SendDataRequest, livekit.SendDataResponse]("livekit.RoomService/SendData")
reg[livekit.UpdateRoomMetadataRequest, livekit.Room]("livekit.RoomService/UpdateRoomMetadata")
reg[livekit.ForwardParticipantRequest, livekit.ForwardParticipantResponse]("livekit.RoomService/ForwardParticipant")
reg[livekit.MoveParticipantRequest, livekit.MoveParticipantResponse]("livekit.RoomService/MoveParticipant")
reg[livekit.PerformRpcRequest, livekit.PerformRpcResponse]("livekit.RoomService/PerformRpc")
// Egress
reg[livekit.StartEgressRequest, livekit.EgressInfo]("livekit.Egress/StartEgress")
reg[livekit.UpdateLayoutRequest, livekit.EgressInfo]("livekit.Egress/UpdateLayout")
reg[livekit.UpdateStreamRequest, livekit.EgressInfo]("livekit.Egress/UpdateStream")
reg[livekit.ListEgressRequest, livekit.ListEgressResponse]("livekit.Egress/ListEgress")
reg[livekit.StopEgressRequest, livekit.EgressInfo]("livekit.Egress/StopEgress")
reg[livekit.RoomCompositeEgressRequest, livekit.EgressInfo]("livekit.Egress/StartRoomCompositeEgress")
reg[livekit.WebEgressRequest, livekit.EgressInfo]("livekit.Egress/StartWebEgress")
reg[livekit.ParticipantEgressRequest, livekit.EgressInfo]("livekit.Egress/StartParticipantEgress")
reg[livekit.TrackCompositeEgressRequest, livekit.EgressInfo]("livekit.Egress/StartTrackCompositeEgress")
reg[livekit.TrackEgressRequest, livekit.EgressInfo]("livekit.Egress/StartTrackEgress")
// Ingress
reg[livekit.CreateIngressRequest, livekit.IngressInfo]("livekit.Ingress/CreateIngress")
reg[livekit.UpdateIngressRequest, livekit.IngressInfo]("livekit.Ingress/UpdateIngress")
reg[livekit.ListIngressRequest, livekit.ListIngressResponse]("livekit.Ingress/ListIngress")
reg[livekit.DeleteIngressRequest, livekit.IngressInfo]("livekit.Ingress/DeleteIngress")
// SIP
reg[livekit.ListSIPTrunkRequest, livekit.ListSIPTrunkResponse]("livekit.SIP/ListSIPTrunk")
reg[livekit.CreateSIPInboundTrunkRequest, livekit.SIPInboundTrunkInfo]("livekit.SIP/CreateSIPInboundTrunk")
reg[livekit.CreateSIPOutboundTrunkRequest, livekit.SIPOutboundTrunkInfo]("livekit.SIP/CreateSIPOutboundTrunk")
reg[livekit.UpdateSIPInboundTrunkRequest, livekit.SIPInboundTrunkInfo]("livekit.SIP/UpdateSIPInboundTrunk")
reg[livekit.UpdateSIPOutboundTrunkRequest, livekit.SIPOutboundTrunkInfo]("livekit.SIP/UpdateSIPOutboundTrunk")
reg[livekit.GetSIPInboundTrunkRequest, livekit.GetSIPInboundTrunkResponse]("livekit.SIP/GetSIPInboundTrunk")
reg[livekit.GetSIPOutboundTrunkRequest, livekit.GetSIPOutboundTrunkResponse]("livekit.SIP/GetSIPOutboundTrunk")
reg[livekit.ListSIPInboundTrunkRequest, livekit.ListSIPInboundTrunkResponse]("livekit.SIP/ListSIPInboundTrunk")
reg[livekit.ListSIPOutboundTrunkRequest, livekit.ListSIPOutboundTrunkResponse]("livekit.SIP/ListSIPOutboundTrunk")
reg[livekit.DeleteSIPTrunkRequest, livekit.SIPTrunkInfo]("livekit.SIP/DeleteSIPTrunk")
reg[livekit.CreateSIPDispatchRuleRequest, livekit.SIPDispatchRuleInfo]("livekit.SIP/CreateSIPDispatchRule")
reg[livekit.UpdateSIPDispatchRuleRequest, livekit.SIPDispatchRuleInfo]("livekit.SIP/UpdateSIPDispatchRule")
reg[livekit.ListSIPDispatchRuleRequest, livekit.ListSIPDispatchRuleResponse]("livekit.SIP/ListSIPDispatchRule")
reg[livekit.DeleteSIPDispatchRuleRequest, livekit.SIPDispatchRuleInfo]("livekit.SIP/DeleteSIPDispatchRule")
reg[livekit.CreateSIPParticipantRequest, livekit.SIPParticipantInfo]("livekit.SIP/CreateSIPParticipant")
reg[livekit.TransferSIPParticipantRequest, emptypb.Empty]("livekit.SIP/TransferSIPParticipant")
// Connector
reg[livekit.DialWhatsAppCallRequest, livekit.DialWhatsAppCallResponse]("livekit.Connector/DialWhatsAppCall")
reg[livekit.DisconnectWhatsAppCallRequest, livekit.DisconnectWhatsAppCallResponse]("livekit.Connector/DisconnectWhatsAppCall")
reg[livekit.ConnectWhatsAppCallRequest, livekit.ConnectWhatsAppCallResponse]("livekit.Connector/ConnectWhatsAppCall")
reg[livekit.AcceptWhatsAppCallRequest, livekit.AcceptWhatsAppCallResponse]("livekit.Connector/AcceptWhatsAppCall")
reg[livekit.ConnectTwilioCallRequest, livekit.ConnectTwilioCallResponse]("livekit.Connector/ConnectTwilioCall")
}
// writeAPIResponse serves a populated, type-correct response for a known API
// method. The response is the reflection-populated default unless the request
// carries an X-Lk-Mock-Response header (protojson), which overrides it
// entirely. Content type (protobuf vs JSON) mirrors the request.
func (h *mockHandler) writeAPIResponse(w http.ResponseWriter, r *http.Request) {
json := strings.Contains(r.Header.Get("Content-Type"), "json")
w.Header().Set(headerRegion, strconv.Itoa(h.regionIndex))
key := strings.TrimPrefix(r.URL.Path, h.twirpPrefix+"/")
spec, ok := apiHandlers[key]
if !ok {
// Unknown/future method: an empty body still decodes to a valid default
// message in every Twirp client.
writeEmptySuccess(w, json)
return
}
body, _ := io.ReadAll(r.Body)
req := spec.newReq()
if json {
_ = protojson.Unmarshal(body, req)
} else {
_ = proto.Unmarshal(body, req)
}
resp := spec.newResp()
if override := r.Header.Get(headerResponse); override != "" {
if err := protojson.Unmarshal([]byte(override), resp); err != nil {
// Malformed override: fall back to the populated default.
resp = spec.newResp()
populateMessage(resp.ProtoReflect(), req.ProtoReflect(), 1)
}
} else {
populateMessage(resp.ProtoReflect(), req.ProtoReflect(), 1)
}
if json {
out, _ := protojson.Marshal(resp)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(out)
} else {
out, _ := proto.Marshal(resp)
w.Header().Set("Content-Type", "application/protobuf")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(out)
}
}
func writeEmptySuccess(w http.ResponseWriter, json bool) {
if json {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("{}"))
} else {
w.Header().Set("Content-Type", "application/protobuf")
w.WriteHeader(http.StatusOK)
}
}
// populateMessage fills a response message with plausible values: it echoes
// scalar fields that share a name with the request, assigns placeholder values
// to id/sid fields, and adds one element to repeated-message (list) fields so
// list endpoints return non-empty results. depth bounds list-element nesting.
func populateMessage(m protoreflect.Message, req protoreflect.Message, depth int) {
fields := m.Descriptor().Fields()
for i := 0; i < fields.Len(); i++ {
fd := fields.Get(i)
// Echo a same-named scalar field from the request (e.g. name, metadata,
// identity, room, timeouts).
if req != nil && fd.Cardinality() != protoreflect.Repeated && isScalarKind(fd.Kind()) {
if rf := req.Descriptor().Fields().ByName(fd.Name()); rf != nil &&
rf.Kind() == fd.Kind() && rf.Cardinality() != protoreflect.Repeated && req.Has(rf) {
m.Set(fd, req.Get(rf))
continue
}
}
// Give id/sid-like string fields a deterministic placeholder.
if fd.Kind() == protoreflect.StringKind && fd.Cardinality() != protoreflect.Repeated && !m.Has(fd) {
n := string(fd.Name())
if n == "id" || n == "sid" || strings.HasSuffix(n, "_id") || strings.HasSuffix(n, "_sid") {
m.Set(fd, protoreflect.ValueOfString("MOCK_"+strings.ToUpper(n)))
continue
}
}
// Populate list endpoints with a single element so clients see results.
if depth > 0 && fd.IsList() && fd.Kind() == protoreflect.MessageKind {
list := m.Mutable(fd).List()
elem := list.NewElement()
populateMessage(elem.Message(), nil, depth-1)
list.Append(elem)
}
}
}
func isScalarKind(k protoreflect.Kind) bool {
switch k {
case protoreflect.BoolKind,
protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Uint32Kind,
protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Uint64Kind,
protoreflect.Sfixed32Kind, protoreflect.Fixed32Kind,
protoreflect.Sfixed64Kind, protoreflect.Fixed64Kind,
protoreflect.FloatKind, protoreflect.DoubleKind,
protoreflect.StringKind, protoreflect.BytesKind:
return true
default:
return false
}
}
+247
View File
@@ -0,0 +1,247 @@
// Copyright 2026 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Command test-server is a programmable mock of the LiveKit server HTTP API,
// used by the server SDKs to test client behavior. See cmd/test-server/README.md.
package main
import (
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"github.com/livekit/protocol/livekit"
"github.com/livekit/protocol/utils/protojson"
)
// X-Lk-Mock-* request headers control the mock's behavior; see the README.
const (
headerFailRegions = "X-Lk-Mock-Fail-Regions"
headerFailMode = "X-Lk-Mock-Fail-Mode"
headerFailStatus = "X-Lk-Mock-Fail-Status"
headerFailTwirpCode = "X-Lk-Mock-Fail-Twirp-Code"
headerDelayMs = "X-Lk-Mock-Delay-Ms"
headerRegionsStatus = "X-Lk-Mock-Regions-Status"
headerResponse = "X-Lk-Mock-Response"
// headerRegion is set on responses to the index of the region that served it.
headerRegion = "X-Lk-Mock-Region"
)
const defaultDelayMs = 30_000
func main() {
portsFlag := flagValue("--ports", "LK_TEST_SERVER_PORTS", "9999,10000,10001,10002")
advertiseHost := flagValue("--advertise-host", "LK_TEST_SERVER_ADVERTISE_HOST", "http://127.0.0.1")
bindAddr := flagValue("--bind", "LK_TEST_SERVER_BIND", "0.0.0.0")
twirpPrefix := flagValue("--twirp-prefix", "LK_TEST_SERVER_TWIRP_PREFIX", "/twirp")
ports, err := parsePorts(portsFlag)
if err != nil {
fmt.Fprintf(os.Stderr, "invalid --ports: %v\n", err)
os.Exit(1)
}
advertiseHost = strings.TrimRight(advertiseHost, "/")
regions := &livekit.RegionSettings{}
for i, p := range ports {
regions.Regions = append(regions.Regions, &livekit.RegionInfo{
Region: fmt.Sprintf("region-%d", i),
Url: fmt.Sprintf("%s:%d", advertiseHost, p),
Distance: int64(i),
})
}
errCh := make(chan error, len(ports))
for i, p := range ports {
srv := &http.Server{
Addr: fmt.Sprintf("%s:%d", bindAddr, p),
Handler: &mockHandler{regionIndex: i, regions: regions, twirpPrefix: twirpPrefix},
}
go func() { errCh <- srv.ListenAndServe() }()
fmt.Printf("test-server: region-%d listening on %s:%d (advertised as %s:%d)\n", i, bindAddr, p, advertiseHost, p)
}
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
select {
case err := <-errCh:
fmt.Fprintf(os.Stderr, "listener failed: %v\n", err)
os.Exit(1)
case <-sigCh:
fmt.Println("test-server: shutting down")
}
}
type mockHandler struct {
regionIndex int
regions *livekit.RegionSettings
twirpPrefix string
}
func (h *mockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/settings/regions":
h.handleRegions(w, r)
case strings.HasPrefix(r.URL.Path, h.twirpPrefix+"/"):
h.handleTwirp(w, r)
case r.URL.Path == "/" || r.URL.Path == "/_test/health":
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
default:
http.NotFound(w, r)
}
}
func (h *mockHandler) handleRegions(w http.ResponseWriter, r *http.Request) {
if status := parseStatus(r.Header.Get(headerRegionsStatus), 0); status != 0 && status != http.StatusOK {
w.WriteHeader(status)
return
}
body, err := protojson.Marshal(h.regions)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "max-age=0")
w.Header().Set(headerRegion, strconv.Itoa(h.regionIndex))
_, _ = w.Write(body)
}
func (h *mockHandler) handleTwirp(w http.ResponseWriter, r *http.Request) {
if h.shouldFail(r) {
h.fail(w, r)
return
}
h.writeAPIResponse(w, r)
}
func (h *mockHandler) shouldFail(r *http.Request) bool {
for _, part := range strings.Split(r.Header.Get(headerFailRegions), ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if idx, err := strconv.Atoi(part); err == nil && idx == h.regionIndex {
return true
}
}
return false
}
func (h *mockHandler) fail(w http.ResponseWriter, r *http.Request) {
switch strings.ToLower(r.Header.Get(headerFailMode)) {
case "drop":
if hj, ok := w.(http.Hijacker); ok {
if conn, _, err := hj.Hijack(); err == nil {
_ = conn.Close()
return
}
}
w.WriteHeader(http.StatusServiceUnavailable)
case "delay":
delay := defaultDelayMs
if ms, err := strconv.Atoi(r.Header.Get(headerDelayMs)); err == nil && ms >= 0 {
delay = ms
}
time.Sleep(time.Duration(delay) * time.Millisecond)
writeTwirpError(w, r, parseStatus(r.Header.Get(headerFailStatus), http.StatusServiceUnavailable))
default:
writeTwirpError(w, r, parseStatus(r.Header.Get(headerFailStatus), http.StatusServiceUnavailable))
}
}
func writeTwirpError(w http.ResponseWriter, r *http.Request, status int) {
code := r.Header.Get(headerFailTwirpCode)
if code == "" {
code = twirpCodeForStatus(status)
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set(headerRegion, "")
w.WriteHeader(status)
_, _ = fmt.Fprintf(w, `{"code":%q,"msg":%q}`, code, fmt.Sprintf("mock failure (status %d)", status))
}
func twirpCodeForStatus(status int) string {
switch {
case status == http.StatusBadRequest:
return "invalid_argument"
case status == http.StatusUnauthorized:
return "unauthenticated"
case status == http.StatusForbidden:
return "permission_denied"
case status == http.StatusNotFound:
return "not_found"
case status == http.StatusTooManyRequests:
return "resource_exhausted"
case status >= 500:
return "unavailable"
default:
return "internal"
}
}
func parseStatus(s string, def int) int {
if s == "" {
return def
}
if v, err := strconv.Atoi(strings.TrimSpace(s)); err == nil && v >= 100 && v <= 599 {
return v
}
return def
}
func parsePorts(s string) ([]int, error) {
var ports []int
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
v, err := strconv.Atoi(part)
if err != nil {
return nil, fmt.Errorf("%q is not a port number", part)
}
ports = append(ports, v)
}
if len(ports) == 0 {
return nil, errors.New("at least one port is required")
}
return ports, nil
}
// flagValue resolves a setting from a --flag, then an environment variable, then a default.
func flagValue(flag, env, def string) string {
prefix := flag + "="
for i, arg := range os.Args[1:] {
if arg == flag {
if i+2 <= len(os.Args[1:]) {
return os.Args[1:][i+1]
}
}
if strings.HasPrefix(arg, prefix) {
return strings.TrimPrefix(arg, prefix)
}
}
if v := os.Getenv(env); v != "" {
return v
}
return def
}
+5
View File
@@ -177,6 +177,11 @@ func TestAll() error {
return mageutil.Run(context.Background(), "go test ./... -count=1 -timeout=4m -v")
}
// runs the SDK test server (cmd/test-server) in the foreground
func TestServer() error {
return mageutil.Run(context.Background(), "go run ./cmd/test-server")
}
// runs golangci-lint
func Lint() error {
if _, err := exec.LookPath("golangci-lint"); err != nil {