Skip to content
Merged
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
4bfdbeb
feat(api): opt-in strict bind — reject non-loopback without explicit …
Snider May 31, 2026
1769524
merge(api): opt-in strict bind + bearer-on-public (RFC.serve.md S2, M…
Snider May 31, 2026
36e48ba
chore(deps): bump core-family externals — core/go v0.10.3, retire sta…
Snider May 31, 2026
c1d0be3
refactor(api): Sonar sweep — dedup string literals + bundle long para…
Snider May 31, 2026
abe120d
feat(api/php): webhooks, API-key hardening, MCP API + OpenAPI docs
Snider May 31, 2026
a702c8a
chore(api): track Sonar-sweep plan (GOAL.md) + openapi-generator config
Snider May 31, 2026
61ac625
docs(api): design spec for WithUpstreamRouter (selector-keyed fan-out)
Snider Jun 6, 2026
2f2fbcd
docs(api): upstream router SSRF → block-by-default + AllowPrivateUpst…
Snider Jun 6, 2026
b9b3bba
docs(api): implementation plan for WithUpstreamRouter
Snider Jun 6, 2026
07d91d7
feat(api): UpstreamRegistry — COW pool table + registration SSRF policy
Snider Jun 6, 2026
6edc926
fix(api): UpstreamRegistry — deep-copy Headers/default pool + contrac…
Snider Jun 6, 2026
fc29777
feat(api): upstreamBalancer — smooth weighted RR + cooldown
Snider Jun 6, 2026
40260ff
test(api): upstreamBalancer — concurrent pick/markFailed race coverage
Snider Jun 6, 2026
df63912
feat(api): WithUpstreamRouter — failover transport + ReverseProxy rou…
Snider Jun 6, 2026
ce45218
refactor(api): harden upstream transport — clone pool, bounded drain,…
Snider Jun 6, 2026
665c2eb
test(api): upstream router integration — routing, failover, streaming…
Snider Jun 6, 2026
8dc3a90
fix(api): upstream router writes via gin ResponseWriter; document mid…
Snider Jun 6, 2026
643062e
test(api): cover multi-path upstream router; correct ResponseWriter r…
Snider Jun 6, 2026
dd588cf
fix(api): bound buffered upstream response size in modifyResponse
Snider Jun 6, 2026
3334dfb
docs(api): design spec for chat-completions remote backend + format a…
Snider Jun 6, 2026
8db17b1
docs(api): implementation plan for chat-completions remote backend
Snider Jun 6, 2026
8bcf50d
feat(api): ModelResolver.Knows — no-load local existence check
Snider Jun 6, 2026
2dcc58d
fix(api): Knows mirrors ResolveModel normalisation (case-insensitive)
Snider Jun 6, 2026
7c01a25
feat(api): chat-completions remote backend — local-first dispatch + O…
Snider Jun 6, 2026
d635abc
refactor(api): chat remote — drop dead Retry-After, use hdr/mime cons…
Snider Jun 6, 2026
4da2c93
feat(api): OllamaAdapter — OpenAI <-> Ollama-native /api/chat
Snider Jun 6, 2026
3c366e7
fix(api): OllamaAdapter transcoder — terminal content, empty-stream p…
Snider Jun 6, 2026
575e235
fix(api): chat endpoint off-loopback gate validates request bearer (f…
Snider Jun 6, 2026
3f79bce
feat(api): AnthropicAdapter — OpenAI <-> Anthropic /v1/messages + e2e…
Snider Jun 6, 2026
3e34993
fix(api): AnthropicAdapter transcoder — no [DONE] on truncated stream…
Snider Jun 6, 2026
93304dd
test(api): ExampleWithChatCompletionsRemote
Snider Jun 6, 2026
23bcdfc
fix(api): OllamaAdapter — stop sequences belong inside options (Ollam…
Snider Jun 6, 2026
c28242d
docs(api): correct chat-remote spec — Ollama stop belongs inside options
Snider Jun 6, 2026
b556c28
docs(api): design spec for OpenAPI describability of the inference su…
Snider Jun 6, 2026
da1077c
docs(api): implementation plan for OpenAPI inference describability
Snider Jun 6, 2026
ce882a5
feat(api): OpenAPI spec includes chat-completions for remote/hybrid b…
Snider Jun 6, 2026
3dde1d7
feat(api): OpenAPI spec documents WithUpstreamRouter paths (deduped p…
Snider Jun 6, 2026
1626a21
fix(api): declare bearer security on proxy spec items + dedup/hybrid …
Snider Jun 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
797 changes: 797 additions & 0 deletions GOAL.md

Large diffs are not rendered by default.

1,482 changes: 1,482 additions & 0 deletions docs/superpowers/plans/2026-06-06-chat-completions-remote-backend.md

Large diffs are not rendered by default.

1,601 changes: 1,601 additions & 0 deletions docs/superpowers/plans/2026-06-06-upstream-router.md

Large diffs are not rendered by default.

Large diffs are not rendered by default.

309 changes: 309 additions & 0 deletions docs/superpowers/specs/2026-06-06-upstream-router-design.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion external/go
Submodule go updated 98 files
+209 −0 action_bench_test.go
+127 −0 api.go
+265 −0 api_bench_test.go
+155 −0 api_example_test.go
+71 −0 app_bench_test.go
+39 −1 array.go
+162 −0 array_bench_test.go
+299 −0 atomic_bench_test.go
+200 −0 config_bench_test.go
+93 −0 context_bench_test.go
+1 −1 contract.go
+132 −0 contract_bench_test.go
+220 −0 core_bench_test.go
+6 −4 data.go
+115 −0 data_bench_test.go
+60 −0 drive_bench_test.go
+14 −2 embed.go
+157 −0 embed_bench_test.go
+19 −2 encode.go
+118 −0 encode_bench_test.go
+142 −0 entitlement_bench_test.go
+24 −1 error.go
+219 −0 error_bench_test.go
+91 −0 format_bench_test.go
+115 −53 fs.go
+316 −0 fs_bench_test.go
+3 −2 hash.go
+154 −0 hash_bench_test.go
+154 −0 i18n_bench_test.go
+101 −0 info_bench_test.go
+84 −0 int_bench_test.go
+95 −3 io.go
+164 −0 io_bench_test.go
+32 −0 io_example_test.go
+48 −0 io_test.go
+67 −28 ipc.go
+140 −0 ipc_bench_test.go
+120 −0 iter_bench_test.go
+22 −2 json.go
+215 −0 json_bench_test.go
+17 −0 json_example_test.go
+29 −0 json_test.go
+13 −7 lock.go
+132 −0 lock_bench_test.go
+93 −54 log.go
+175 −0 log_bench_test.go
+11 −4 map.go
+143 −0 map_bench_test.go
+35 −8 math.go
+197 −0 math_bench_test.go
+71 −0 net_bench_test.go
+200 −0 options_bench_test.go
+29 −5 path.go
+197 −0 path_bench_test.go
+47 −12 random.go
+111 −0 random_bench_test.go
+14 −0 random_example_test.go
+124 −0 reflect_bench_test.go
+138 −0 regexp_bench_test.go
+213 −0 registry_bench_test.go
+159 −0 result_bench_test.go
+32 −1 runtime.go
+144 −0 runtime_bench_test.go
+29 −0 runtime_example_test.go
+85 −0 scanner_bench_test.go
+1 −3 service.go
+116 −0 service_bench_test.go
+107 −0 sha3_bench_test.go
+42 −4 slice.go
+194 −0 slice_bench_test.go
+19 −0 slice_example_test.go
+68 −0 sql_bench_test.go
+121 −8 string.go
+295 −0 string_bench_test.go
+54 −0 string_example_test.go
+78 −0 string_test.go
+294 −0 sync_bench_test.go
+1 −1 table.go
+77 −0 table_bench_test.go
+109 −0 template_bench_test.go
+208 −2 tests/cli/v090-upgrade/audit.sh
+9 −2 tests/cli/v090-upgrade/ax7-gaps.py
+8 −2 tests/cli/v090-upgrade/example-gaps.py
+7 −2 tests/cli/v090-upgrade/file-presence.py
+1 −1 tests/cli/v090-upgrade/identical-triplets.py
+1 −1 tests/cli/v090-upgrade/test-stubs.py
+1 −1 tests/cli/v090-upgrade/unreferenced-symbols.py
+46 −0 time.go
+126 −0 time_bench_test.go
+39 −0 time_example_test.go
+94 −0 unicode_bench_test.go
+176 −0 unsafe.go
+214 −0 unsafe_bench_test.go
+137 −0 unsafe_test.go
+95 −0 url_bench_test.go
+64 −0 user_bench_test.go
+31 −4 utils.go
+189 −0 utils_bench_test.go
2 changes: 1 addition & 1 deletion external/go-inference
Submodule go-inference updated 181 files
2 changes: 1 addition & 1 deletion external/go-log
12 changes: 10 additions & 2 deletions go.work.sum
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
146 changes: 140 additions & 6 deletions go/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package api
import (
"context"
"iter"
"net" // Note: AX-6 — net.SplitHostPort/ParseIP are structural for loopback bind classification; no core primitive
"net/http" // Note: AX-6 — structural HTTP boundary for Handler/WebSocket contracts; no core primitive
"reflect" // Note: AX-6 — reflect is structural for runtime nil-pointer detection in handler binding; no core primitive
"slices"
Expand All @@ -22,6 +23,17 @@ import (

const defaultAddr = ":8080"

var (
// ErrNonLoopbackBind is returned by Serve under strict bind mode when the
// configured listen address is not loopback and WithPublicBind was not
// set. Strict mode is opt-in via WithStrictBind / WithLoopbackOnly.
ErrNonLoopbackBind = core.NewError("api: strict bind rejects non-loopback address without WithPublicBind")
// ErrPublicBindNoBearer is returned by Serve under strict bind mode when a
// public (non-loopback) bind is requested without a bearer credential
// supplied via WithBearerAuth.
ErrPublicBindNoBearer = core.NewError("api: strict bind rejects public address without WithBearerAuth")
)

// shutdownTimeout is the maximum duration to wait for in-flight requests
// to complete during graceful shutdown.
const shutdownTimeout = 10 * time.Second
Expand Down Expand Up @@ -83,10 +95,37 @@ type Engine struct {
i18nConfig I18nConfig
openAPISpecEnabled bool
openAPISpecPath string
// strictBind, when set via WithStrictBind / WithLoopbackOnly, makes
// Serve refuse a non-loopback listen address unless publicBind is also
// set, and refuse to serve a public bind without a bearer credential.
// Default false preserves the historic permissive behaviour so existing
// consumers (go-ml, go-ai, desktop, core-agent) are not broken.
strictBind bool
// publicBind, when set via WithPublicBind, is the explicit opt-in that
// allows a non-loopback bind under strict mode. It carries no effect
// when strictBind is false. A public bind still requires a bearer.
publicBind bool
// bearerConfigured records that a bearer credential was supplied via
// WithBearerAuth. Strict mode refuses to serve a public listener
// without one.
bearerConfigured bool
// bearerToken is the static bearer credential supplied via WithBearerAuth.
// The chat endpoint's off-loopback gate validates the inbound request
// against this token directly so it fails closed independently of the
// bearer middleware's path coverage.
bearerToken string
// noRouteHandler is the SPA / fallback handler invoked when no
// registered route matches the request. Set via WithNoRoute; nil
// means gin returns 404 with its default body.
noRouteHandler gin.HandlerFunc
noRouteHandler gin.HandlerFunc
// upstreamRouter, when set via WithUpstreamRouter, mounts a selector-keyed
// reverse proxy over a pool of HTTP upstreams at the configured paths.
upstreamRouter *upstreamRouterConfig
// chatRemote, when set via WithChatCompletionsRemote, adds a remote backend
// to the chat completions endpoint (local-first dispatch).
chatRemote *chatRemoteConfig
// chatAllowRemote permits non-loopback chat clients when a bearer is set.
chatAllowRemote bool
}

// New creates an Engine with the given options.
Expand All @@ -109,7 +148,7 @@ func New(opts ...Option) (
opt(e)
}
// Apply calibrated defaults for optional subsystems.
if e.chatCompletionsResolver != nil && core.Trim(e.chatCompletionsPath) == "" {
if (e.chatCompletionsResolver != nil || e.chatRemote != nil) && core.Trim(e.chatCompletionsPath) == "" {
e.chatCompletionsPath = defaultChatCompletionsPath
}
return e, nil
Expand Down Expand Up @@ -247,6 +286,10 @@ func (e *Engine) Handler() http.Handler {
func (e *Engine) Serve(ctx context.Context) (
_ error,
) {
if err := e.validateBind(); err != nil {
return err
}

srv := &http.Server{
Addr: e.addr,
Handler: e.build(),
Expand Down Expand Up @@ -289,6 +332,83 @@ func (e *Engine) Serve(ctx context.Context) (
return <-errCh
}

// validateBind enforces the strict bind invariants before Serve binds a
// listener. It is a no-op unless WithStrictBind / WithLoopbackOnly was set, so
// existing consumers that bind non-loopback addresses are unaffected.
//
// Under strict mode:
// - a loopback address always serves;
// - a non-loopback address is rejected unless WithPublicBind is set;
// - a non-loopback address with WithPublicBind set still requires a bearer
// credential (WithBearerAuth), and is otherwise rejected.
//
// Example:
//
// e, _ := api.New(api.WithAddr("0.0.0.0:8787"), api.WithStrictBind())
// err := e.Serve(ctx) // err == api.ErrNonLoopbackBind
func (e *Engine) validateBind() (
_ error,
) {
if !e.strictBind {
return nil
}
if addrIsLoopback(e.addr) {
return nil
}
if !e.publicBind {
return core.E("api.bind", e.addr, ErrNonLoopbackBind)
}
if !e.bearerConfigured {
return core.E("api.bind", e.addr, ErrPublicBindNoBearer)
}
return nil
}

// addrIsLoopback reports whether a listen address binds only the loopback
// interface. The host portion is parsed from "host:port"; a bare ":port" or an
// unspecified host ("0.0.0.0", "::", empty) is treated as non-loopback because
// it binds all interfaces. The textual host "localhost" is treated as loopback.
//
// Example:
//
// addrIsLoopback("127.0.0.1:8787") // true
// addrIsLoopback("[::1]:8787") // true
// addrIsLoopback("localhost:8787") // true
// addrIsLoopback("0.0.0.0:8787") // false
// addrIsLoopback(":8787") // false
func addrIsLoopback(addr string) bool {
addr = core.Trim(addr)
if addr == "" {
return false
}

host, _, err := net.SplitHostPort(addr)
if err != nil {
// No port separator (or malformed); treat the whole string as the host.
host = addr
}

host = core.Trim(host)
if host == "" {
// Bare ":port" — binds every interface, not loopback.
return false
}
if host == "localhost" {
return true
}

ip := net.ParseIP(host)
if ip == nil {
// A named host other than localhost is not a loopback guarantee.
return false
}
if ip.IsUnspecified() {
// 0.0.0.0 / :: bind all interfaces.
return false
}
return ip.IsLoopback()
}

// SetNoRoute attaches or replaces the fallback handler invoked when
// no registered route matches the incoming request. Mirrors the
// WithNoRoute Option but is callable after construction — useful
Expand Down Expand Up @@ -329,10 +449,24 @@ func (e *Engine) build() *gin.Engine {
c.JSON(http.StatusOK, OK("healthy"))
})

// Mount the local OpenAI-compatible chat completion endpoint when configured.
if e.chatCompletionsResolver != nil {
h := newChatCompletionsHandler(e.chatCompletionsResolver)
r.POST(e.chatCompletionsPath, h.ServeHTTP)
// Mount the OpenAI-compatible chat completion endpoint when a local resolver
// and/or a remote backend is configured.
if e.chatCompletionsResolver != nil || e.chatRemote != nil {
path := e.chatCompletionsPath
if core.Trim(path) == "" {
path = defaultChatCompletionsPath
}
h := newChatCompletionsHandler(e.chatCompletionsResolver, e.chatRemote, e.chatAllowRemote, bearerValidator(e.bearerToken))
r.POST(path, h.ServeHTTP)
}

// Mount the selector-keyed upstream router when configured.
if e.upstreamRouter != nil {
proxy := e.upstreamRouter.buildProxy()
h := e.upstreamRouter.handler(proxy)
for _, p := range e.upstreamRouter.paths {
r.Any(p, h)
}
}

if e.sdkGenEnabled {
Expand Down
26 changes: 14 additions & 12 deletions go/api_describable_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ type describableSpecGroup struct {
descs []api.RouteDescription
}

func (g *describableSpecGroup) Name() string { return g.name }
func (g *describableSpecGroup) BasePath() string { return g.basePath }
func (g *describableSpecGroup) RegisterRoutes(rg *gin.RouterGroup) {}
func (g *describableSpecGroup) Describe() []api.RouteDescription { return g.descs }
func (g *describableSpecGroup) Name() string { return g.name }
func (g *describableSpecGroup) BasePath() string { return g.basePath }
func (g *describableSpecGroup) RegisterRoutes(rg *gin.RouterGroup) {
// Required by RouteGroup; routes are registered through the Describe path only.
}
func (g *describableSpecGroup) Describe() []api.RouteDescription { return g.descs }

type describableHandler struct {
desc api.RouteDescription
Expand Down Expand Up @@ -75,12 +77,12 @@ func buildDescribableOperation(t *testing.T, group api.RouteGroup, path, method

data, err := builder.Build([]api.RouteGroup{group})
if err != nil {
t.Fatalf("unexpected error: %v", err)
t.Fatalf(fmtTestUnexpectedErr, err)
}

var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
t.Fatalf(fmtTestInvalidJSON, err)
}

paths := spec["paths"].(map[string]any)
Expand Down Expand Up @@ -146,15 +148,15 @@ func TestDescribable_Good_HandlerMetadataFlowsToOpenAPI(t *testing.T) {

tags, ok := operation["tags"].([]any)
if !ok {
t.Fatalf("expected tags array, got %T", operation["tags"])
t.Fatalf(fmtTestExpectedTags, operation["tags"])
}
if len(tags) != 2 || tags[0] != "widgets" || tags[1] != "catalog" {
t.Fatalf("expected handler tags, got %v", tags)
}

requestBody := operation["requestBody"].(map[string]any)
content := requestBody["content"].(map[string]any)
schema := content["application/json"].(map[string]any)["schema"].(map[string]any)
schema := content[mimeJSON].(map[string]any)["schema"].(map[string]any)
properties := schema["properties"].(map[string]any)
if _, ok := properties["name"]; !ok {
t.Fatal("expected request body schema from handler Describe")
Expand All @@ -173,7 +175,7 @@ func TestDescribable_Bad_MissingHandlerMetadataFallsBackSafely(t *testing.T) {
descs: []api.RouteDescription{
{
Method: http.MethodGet,
Path: "/status",
Path: pathStatus,
Summary: "Widget status",
Description: "Returns widget availability.",
Tags: []string{"status"},
Expand All @@ -196,7 +198,7 @@ func TestDescribable_Bad_MissingHandlerMetadataFallsBackSafely(t *testing.T) {

tags, ok := operation["tags"].([]any)
if !ok {
t.Fatalf("expected tags array, got %T", operation["tags"])
t.Fatalf(fmtTestExpectedTags, operation["tags"])
}
if len(tags) != 1 || tags[0] != "status" {
t.Fatalf("expected route tag fallback, got %v", tags)
Expand All @@ -210,7 +212,7 @@ func TestDescribable_Ugly_NilHandlerIsIgnored(t *testing.T) {
descs: []api.RouteDescription{
{
Method: http.MethodGet,
Path: "/status",
Path: pathStatus,
Handler: (*describableHandler)(nil),
},
},
Expand All @@ -224,7 +226,7 @@ func TestDescribable_Ugly_NilHandlerIsIgnored(t *testing.T) {

tags, ok := operation["tags"].([]any)
if !ok {
t.Fatalf("expected tags array, got %T", operation["tags"])
t.Fatalf(fmtTestExpectedTags, operation["tags"])
}
if len(tags) != 1 || tags[0] != "widgets" {
t.Fatalf("expected group-name tag fallback, got %v", tags)
Expand Down
18 changes: 10 additions & 8 deletions go/api_renderable_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ type renderableSpecGroup struct {
descs []api.RouteDescription
}

func (g *renderableSpecGroup) Name() string { return g.name }
func (g *renderableSpecGroup) BasePath() string { return g.basePath }
func (g *renderableSpecGroup) RegisterRoutes(rg *gin.RouterGroup) {}
func (g *renderableSpecGroup) Describe() []api.RouteDescription { return g.descs }
func (g *renderableSpecGroup) Name() string { return g.name }
func (g *renderableSpecGroup) BasePath() string { return g.basePath }
func (g *renderableSpecGroup) RegisterRoutes(rg *gin.RouterGroup) {
// Required by RouteGroup; routes are registered through the Describe path only.
}
func (g *renderableSpecGroup) Describe() []api.RouteDescription { return g.descs }

type renderableHandler struct {
hints api.RenderHints
Expand All @@ -43,12 +45,12 @@ func buildRenderableOperation(t *testing.T, group api.RouteGroup, path, method s

data, err := builder.Build([]api.RouteGroup{group})
if err != nil {
t.Fatalf("unexpected error: %v", err)
t.Fatalf(fmtTestUnexpectedErr, err)
}

var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
t.Fatalf(fmtTestInvalidJSON, err)
}

paths := spec["paths"].(map[string]any)
Expand Down Expand Up @@ -144,7 +146,7 @@ func TestRenderable_Bad_EmptyHintsAreOmittedSafely(t *testing.T) {
descs: []api.RouteDescription{
{
Method: http.MethodGet,
Path: "/status",
Path: pathStatus,
Handler: &renderableHandler{},
},
},
Expand All @@ -164,7 +166,7 @@ func TestRenderable_Ugly_NilHandlerIsIgnored(t *testing.T) {
descs: []api.RouteDescription{
{
Method: http.MethodGet,
Path: "/status",
Path: pathStatus,
Handler: (*renderableHandler)(nil),
},
},
Expand Down
Loading
Loading