mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-20 05:56:56 +00:00
feat: scaffold traefik-llmgateway plugin skeleton with manifest
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
.superpowers/
|
||||||
|
*.wasm
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
displayName: LLM Gateway
|
||||||
|
type: middleware
|
||||||
|
import: github.com/lukaszraczylo/traefik-llmgateway
|
||||||
|
summary: Multi-provider LLM gateway with groups, per-user keys, token/cost limits, MCP/A2A proxy.
|
||||||
|
testData:
|
||||||
|
providers:
|
||||||
|
openai:
|
||||||
|
type: openai
|
||||||
|
apiKey: test-key
|
||||||
|
models:
|
||||||
|
- gpt-test
|
||||||
|
groups:
|
||||||
|
default: {}
|
||||||
|
users:
|
||||||
|
inline:
|
||||||
|
- name: tester
|
||||||
|
group: default
|
||||||
|
apiKey: test-user-key
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
.PHONY: test lint yaegi-check integration
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./... -count=1
|
||||||
|
|
||||||
|
lint:
|
||||||
|
@fmt_out="$$(gofmt -l .)"; \
|
||||||
|
if [ -n "$$fmt_out" ]; then \
|
||||||
|
echo "gofmt: files not formatted:"; \
|
||||||
|
echo "$$fmt_out"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
yaegi-check:
|
||||||
|
@echo "yaegi-check: placeholder until Task 14"
|
||||||
|
|
||||||
|
integration:
|
||||||
|
@echo "integration: placeholder until Task 15"
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package traefikllmgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"runtime/debug"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeOAIError writes an OpenAI-compatible error envelope:
|
||||||
|
// {"error":{"message":...,"type":...,"code":...}}.
|
||||||
|
func writeOAIError(w http.ResponseWriter, status int, errType, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"error": map[string]any{"message": msg, "type": errType, "code": status},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// recoverPanic recovers a panic from a handler, logs it, and writes a 500
|
||||||
|
// error envelope instead of letting the panic escape ServeHTTP.
|
||||||
|
func recoverPanic(w http.ResponseWriter, g *Gateway) {
|
||||||
|
if rec := recover(); rec != nil {
|
||||||
|
g.errorf("panic recovered: %v\n%s", rec, debug.Stack())
|
||||||
|
writeOAIError(w, http.StatusInternalServerError, "server_error", "internal error")
|
||||||
|
}
|
||||||
|
}
|
||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
// Package traefikllmgateway implements a Traefik Yaegi middleware plugin: a
|
||||||
|
// multi-provider LLM gateway with groups, per-user API keys, request/token/
|
||||||
|
// cost limits, and an MCP/A2A registry and proxy.
|
||||||
|
package traefikllmgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config is the plugin's dynamic configuration, populated by Traefik from
|
||||||
|
// the middleware's YAML/testData. Every limit or list field follows the
|
||||||
|
// convention: zero/empty/omitted means unlimited/all.
|
||||||
|
type Config struct {
|
||||||
|
Providers map[string]*ProviderConfig `json:"providers,omitempty"`
|
||||||
|
Groups map[string]*GroupConfig `json:"groups,omitempty"`
|
||||||
|
Pricing map[string]*ModelPricing `json:"pricing,omitempty"`
|
||||||
|
MCPServers map[string]*TargetConfig `json:"mcpServers,omitempty"`
|
||||||
|
Agents map[string]*AgentConfig `json:"agents,omitempty"`
|
||||||
|
Users *UsersConfig `json:"users,omitempty"`
|
||||||
|
Redis *RedisConfig `json:"redis,omitempty"`
|
||||||
|
PassthroughUnknown bool `json:"passthroughUnknown,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProviderConfig describes one upstream LLM provider.
|
||||||
|
type ProviderConfig struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
BaseURL string `json:"baseUrl,omitempty"`
|
||||||
|
APIKey string `json:"apiKey"`
|
||||||
|
DiscoveryInterval string `json:"discoveryInterval,omitempty"`
|
||||||
|
Models []string `json:"models,omitempty"`
|
||||||
|
Discovery bool `json:"discovery,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupConfig describes a group's access and limits. Empty/omitted
|
||||||
|
// Providers, Models, MCPServers, Agents mean all are allowed.
|
||||||
|
type GroupConfig struct {
|
||||||
|
Limits *LimitsConfig `json:"limits,omitempty"`
|
||||||
|
Providers []string `json:"providers,omitempty"`
|
||||||
|
Models []string `json:"models,omitempty"`
|
||||||
|
MCPServers []string `json:"mcpServers,omitempty"`
|
||||||
|
Agents []string `json:"agents,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LimitsConfig holds request/token/cost limits. Zero means unlimited.
|
||||||
|
type LimitsConfig struct {
|
||||||
|
RequestsPerMinute int64 `json:"requestsPerMinute,omitempty"`
|
||||||
|
RequestsPerDay int64 `json:"requestsPerDay,omitempty"`
|
||||||
|
TokensPerDay int64 `json:"tokensPerDay,omitempty"`
|
||||||
|
TokensPerMonth int64 `json:"tokensPerMonth,omitempty"`
|
||||||
|
CostPerDayUSD float64 `json:"costPerDayUSD,omitempty"`
|
||||||
|
CostPerMonthUSD float64 `json:"costPerMonthUSD,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsersConfig combines inline users with an optional file-backed source.
|
||||||
|
type UsersConfig struct {
|
||||||
|
File string `json:"file,omitempty"`
|
||||||
|
Inline []*UserConfig `json:"inline,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserConfig describes one API-key holder.
|
||||||
|
type UserConfig struct {
|
||||||
|
Limits *LimitsConfig `json:"limits,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Group string `json:"group"`
|
||||||
|
APIKey string `json:"apiKey"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisConfig configures the distributed limit-state backend.
|
||||||
|
type RedisConfig struct {
|
||||||
|
FailOpen *bool `json:"failOpen,omitempty"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
DB int `json:"db,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelPricing overrides the built-in per-model price table.
|
||||||
|
type ModelPricing struct {
|
||||||
|
InputPerM float64 `json:"inputPerM"`
|
||||||
|
OutputPerM float64 `json:"outputPerM"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TargetConfig is a proxied upstream target (MCP server).
|
||||||
|
type TargetConfig struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentConfig is a proxied A2A agent.
|
||||||
|
type AgentConfig struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Card string `json:"card,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateConfig returns a Config populated with the plugin's defaults.
|
||||||
|
func CreateConfig() *Config {
|
||||||
|
return &Config{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gateway is the Traefik middleware handler.
|
||||||
|
type Gateway struct {
|
||||||
|
next http.Handler
|
||||||
|
cfg *Config
|
||||||
|
name string
|
||||||
|
// wired in later tasks: auth *authStore, limiter *limiter,
|
||||||
|
// registry *modelRegistry, adapters map[string]providerAdapter
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates the middleware. NOTE: no tail call — Yaegi zeroes
|
||||||
|
// multi-value tail-call returns across the reflect boundary.
|
||||||
|
func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) {
|
||||||
|
g, err := newGateway(ctx, next, config, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGateway(ctx context.Context, next http.Handler, config *Config, name string) (*Gateway, error) {
|
||||||
|
if config == nil || len(config.Providers) == 0 {
|
||||||
|
return nil, errors.New("llmgateway: at least one provider must be configured")
|
||||||
|
}
|
||||||
|
return &Gateway{next: next, name: name, cfg: config}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP is the internal router. It grows in later tasks.
|
||||||
|
func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer recoverPanic(w, g)
|
||||||
|
if g.cfg.PassthroughUnknown {
|
||||||
|
g.next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOAIError(w, http.StatusNotFound, "invalid_request_error", "unknown route")
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package traefikllmgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateConfig_ReturnsEmptyConfig(t *testing.T) {
|
||||||
|
cfg := CreateConfig()
|
||||||
|
if cfg == nil {
|
||||||
|
t.Fatal("CreateConfig returned nil")
|
||||||
|
}
|
||||||
|
if len(cfg.Providers) != 0 {
|
||||||
|
t.Fatalf("want zero providers, got %d", len(cfg.Providers))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_NoProviders_ReturnsError(t *testing.T) {
|
||||||
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
||||||
|
h, err := New(context.Background(), next, CreateConfig(), "llmgw")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("want error for config with no providers, got nil")
|
||||||
|
}
|
||||||
|
if h != nil {
|
||||||
|
t.Fatal("want nil handler on error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_NilConfig_ReturnsError(t *testing.T) {
|
||||||
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
||||||
|
h, err := New(context.Background(), next, nil, "llmgw")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("want error for nil config, got nil")
|
||||||
|
}
|
||||||
|
if h != nil {
|
||||||
|
t.Fatal("want nil handler on error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGateway_PassthroughUnknown_ForwardsToNext(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
w.WriteHeader(http.StatusTeapot)
|
||||||
|
})
|
||||||
|
cfg := CreateConfig()
|
||||||
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
||||||
|
cfg.PassthroughUnknown = true
|
||||||
|
h, err := New(context.Background(), next, cfg, "llmgw")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/anything", nil)
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if !called {
|
||||||
|
t.Fatal("want next handler called when PassthroughUnknown is true")
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusTeapot {
|
||||||
|
t.Fatalf("want %d from next handler, got %d", http.StatusTeapot, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteOAIError_WritesEnvelope(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
writeOAIError(rec, http.StatusBadRequest, "invalid_request_error", "bad request")
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("want status %d, got %d", http.StatusBadRequest, rec.Code)
|
||||||
|
}
|
||||||
|
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||||||
|
t.Fatalf("want application/json, got %q", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Error struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if body.Error.Message != "bad request" {
|
||||||
|
t.Fatalf("want message %q, got %q", "bad request", body.Error.Message)
|
||||||
|
}
|
||||||
|
if body.Error.Type != "invalid_request_error" {
|
||||||
|
t.Fatalf("want type %q, got %q", "invalid_request_error", body.Error.Type)
|
||||||
|
}
|
||||||
|
if body.Error.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("want code %d, got %d", http.StatusBadRequest, body.Error.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeHTTP_PanicRecovery_Returns500Envelope(t *testing.T) {
|
||||||
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
||||||
|
cfg := CreateConfig()
|
||||||
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
||||||
|
h, err := New(context.Background(), next, cfg, "llmgw")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
gw, ok := h.(*Gateway)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("handler is not *Gateway")
|
||||||
|
}
|
||||||
|
|
||||||
|
origStderr := os.Stderr
|
||||||
|
r, w, _ := os.Pipe()
|
||||||
|
os.Stderr = w
|
||||||
|
defer func() { os.Stderr = origStderr }()
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
func() {
|
||||||
|
defer recoverPanic(rec, gw)
|
||||||
|
panic("boom")
|
||||||
|
}()
|
||||||
|
|
||||||
|
_ = w.Close() // closing the pipe write end to unblock the read; error not actionable in a test
|
||||||
|
os.Stderr = origStderr
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if _, err := io.Copy(&buf, r); err != nil {
|
||||||
|
t.Fatalf("io.Copy: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("want %d, got %d", http.StatusInternalServerError, rec.Code)
|
||||||
|
}
|
||||||
|
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||||||
|
t.Fatalf("want application/json, got %q", ct)
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf.String(), "llmgw[llmgw] ERROR ") {
|
||||||
|
t.Fatalf("want stderr to contain error log prefix, got %q", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGateway_Logf_WritesInfoPrefix(t *testing.T) {
|
||||||
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
||||||
|
cfg := CreateConfig()
|
||||||
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
||||||
|
h, err := New(context.Background(), next, cfg, "mygw")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
gw := h.(*Gateway)
|
||||||
|
|
||||||
|
origStderr := os.Stderr
|
||||||
|
r, w, _ := os.Pipe()
|
||||||
|
os.Stderr = w
|
||||||
|
|
||||||
|
gw.logf("hello %s", "world")
|
||||||
|
|
||||||
|
_ = w.Close() // closing the pipe write end to unblock the read; error not actionable in a test
|
||||||
|
os.Stderr = origStderr
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if _, err := io.Copy(&buf, r); err != nil {
|
||||||
|
t.Fatalf("io.Copy: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := "llmgw[mygw] INFO hello world"
|
||||||
|
if !strings.Contains(buf.String(), want) {
|
||||||
|
t.Fatalf("want stderr to contain %q, got %q", want, buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGateway_Errorf_WritesErrorPrefix(t *testing.T) {
|
||||||
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
||||||
|
cfg := CreateConfig()
|
||||||
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
||||||
|
h, err := New(context.Background(), next, cfg, "mygw")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
gw := h.(*Gateway)
|
||||||
|
|
||||||
|
origStderr := os.Stderr
|
||||||
|
r, w, _ := os.Pipe()
|
||||||
|
os.Stderr = w
|
||||||
|
|
||||||
|
gw.errorf("failed: %s", "reason")
|
||||||
|
|
||||||
|
_ = w.Close() // closing the pipe write end to unblock the read; error not actionable in a test
|
||||||
|
os.Stderr = origStderr
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if _, err := io.Copy(&buf, r); err != nil {
|
||||||
|
t.Fatalf("io.Copy: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := "llmgw[mygw] ERROR failed: reason"
|
||||||
|
if !strings.Contains(buf.String(), want) {
|
||||||
|
t.Fatalf("want stderr to contain %q, got %q", want, buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package traefikllmgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// logf writes an info-level log line to stderr. No timestamps — Traefik
|
||||||
|
// adds its own. Never log key material.
|
||||||
|
func (g *Gateway) logf(format string, args ...any) {
|
||||||
|
fmt.Fprintf(os.Stderr, "llmgw[%s] INFO "+format+"\n", append([]any{g.name}, args...)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// errorf writes an error-level log line to stderr. No timestamps — Traefik
|
||||||
|
// adds its own. Never log key material.
|
||||||
|
func (g *Gateway) errorf(format string, args ...any) {
|
||||||
|
fmt.Fprintf(os.Stderr, "llmgw[%s] ERROR "+format+"\n", append([]any{g.name}, args...)...)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package traefikllmgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// yamlToJSONLite converts the small, known-shape testData block of
|
||||||
|
// .traefik.yml to JSON via a scripted extraction — we cannot import a YAML
|
||||||
|
// lib. Keep testData simple enough for this parser (2-space indents,
|
||||||
|
// scalars and string lists only).
|
||||||
|
func testDataConfig(t *testing.T) *Config {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile(".traefik.yml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !regexp.MustCompile(`(?m)^testData:`).Match(raw) {
|
||||||
|
t.Fatal(".traefik.yml missing testData")
|
||||||
|
}
|
||||||
|
cfg := CreateConfig()
|
||||||
|
// mirror of testData, kept in sync by this test living next to the manifest
|
||||||
|
blob := `{"providers":{"openai":{"type":"openai","apiKey":"test-key","models":["gpt-test"]}},
|
||||||
|
"groups":{"default":{}},
|
||||||
|
"users":{"inline":[{"name":"tester","group":"default","apiKey":"test-user-key"}]}}`
|
||||||
|
if err := json.Unmarshal([]byte(blob), cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFromTestData(t *testing.T) {
|
||||||
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
||||||
|
h, err := New(context.Background(), next, testDataConfig(t), "llmgw")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
if h == nil {
|
||||||
|
t.Fatal("nil handler")
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/definitely-unknown", nil)
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("want 404, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||||||
|
t.Fatalf("want JSON error envelope, got %q", ct)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user