cmd/micro gateway HTTP->RPC proxy: Endpoint handling via URL parsing is not implemented (#4879)

* fix(gateway): add /api/{service}/{method} HTTP-to-RPC proxy

The gateway only rendered the /api explorer page; any /api/{service}/{endpoint}
path fell through to 404. Add a proxy that resolves the endpoint from the
registry, checks scopes, and forwards the request body to the RPC client.
Accepts both /api/{service}/{method} and /api/{service}/{pkg}/{method} forms.

* fix(readme): old links, add protoc and docs for docker

* test(store): de-flake file store expiry window in first block

The initial record used a 150ms expiry read back immediately after a
bbolt write. Under -race/-cover on a loaded runner, the fsync'd write
or instrumented read can exceed 150ms, so the record has already
expired and Read("Hello") returns ErrNotFound, failing the test early.

Widen the first block to the same 1s expiry / 2s sleep pattern used by
the sibling blocks since #3789/#3828. The pre-expiry read stays well
inside 1s; the post-expiry read comfortably exceeds it.
This commit is contained in:
Alexander Serheyev
2026-08-06 09:27:59 +03:00
committed by GitHub
parent b0d4ac7e01
commit a6d4272e21
5 changed files with 75 additions and 13 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ RUN addgroup --gid "$GID" "$GROUPNAME" \
--uid "$UID" "$USER"
ENV PATH=/usr/local/go/bin:$PATH
RUN apk --no-cache add git make curl
RUN apk --no-cache add git make curl protoc
COPY --from=golang:1.26.0-alpine /usr/local/go /usr/local/go
COPY $TARGETPLATFORM/micro /usr/local/go/bin/
+11 -3
View File
@@ -9,11 +9,11 @@ Go Micro gives you the harness as Go code. Build an agent and it gets a model, m
## Sponsors
<a href="https://go-micro.dev/blog/3"><img src="https://upload.wikimedia.org/wikipedia/commons/7/78/Anthropic_logo.svg" height="26" /></a>
<a href="https://go-micro.dev/blog/2026/03/04/building-the-ai-native-future-of-go-micro-with-claude.html"><img src="https://upload.wikimedia.org/wikipedia/commons/7/78/Anthropic_logo.svg" height="26" /></a>
&nbsp;&nbsp;
<a href="https://go-micro.dev/blog/29"><img src="https://upload.wikimedia.org/wikipedia/commons/4/4d/OpenAI_Logo.svg" height="26" /></a>
<a href="https://go-micro.dev/blog/2026/06/23/go-micro-joins-openai-s-codex-for-open-source.html"><img src="https://upload.wikimedia.org/wikipedia/commons/4/4d/OpenAI_Logo.svg" height="26" /></a>
&nbsp;&nbsp;
<a href="https://go-micro.dev/blog/8"><img src="https://www.atlascloud.ai/logo.svg" height="26" /></a>
<a href="https://go-micro.dev/blog/2026/05/28/atlas-cloud-sponsors-go-micro-300-ai-models-one-integration.html"><img src="https://www.atlascloud.ai/logo.svg" height="26" /></a>
**Want to support Go Micro and see your logo here?** [Become a sponsor](https://discord.gg/G8Gk5j3uXr) — reach out on Discord.
@@ -66,6 +66,14 @@ cd helloworld
micro run
```
Prefer Docker? The `micro` image (Docker Hub `micro/micro` or GitHub Container Registry `ghcr.io/micro/go-micro`) bundles the CLI and its runtime dependencies:
```bash
docker pull micro/micro:latest # or ghcr.io/micro/go-micro:latest
docker run --rm -it micro/micro new helloworld
docker run --rm -it --network host -v "$(pwd)":/micro/helloworld micro/micro run
```
Then in another terminal:
```bash
+54
View File
@@ -906,6 +906,60 @@ Use the token printed at startup, or generate more on the <a href='/auth/tokens'
_ = renderPage(w, tmpls.api, apiData)
return
}
// HTTP->RPC proxy: /api/{service}/{method} (e.g. /api/helloworld/Helloworld.Call)
// also accepts /api/{service}/{pkg}/{method} as linked from the API explorer.
if strings.HasPrefix(path, "/api/") {
parts := strings.Split(strings.TrimPrefix(path, "/api/"), "/")
var service, endpoint string
switch len(parts) {
case 2:
service, endpoint = parts[0], parts[1]
case 3:
service, endpoint = parts[0], parts[1]+"."+parts[2]
default:
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("Not found"))
return
}
svcs, err := registry.GetService(service)
if err != nil || len(svcs) == 0 {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("Service not found: " + service))
return
}
valid := false
for _, ep := range svcs[0].Endpoints {
if ep.Name == endpoint {
valid = true
break
}
}
if !valid {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("Endpoint not found: " + endpoint))
return
}
if !checkEndpointScopes(w, r, service+"."+endpoint) {
return
}
inputBytes, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return
}
rpcReq := client.DefaultClient.NewRequest(service, endpoint, &codecBytes.Frame{Data: inputBytes})
var rsp codecBytes.Frame
if err := client.DefaultClient.Call(r.Context(), rpcReq, &rsp); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
w.Write([]byte(`{"error":` + strconv.Quote(err.Error()) + `}`))
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(rsp.Data)
return
}
if path == "/services" {
// Do NOT include SidebarEndpoints on this page
services, _ := registry.ListServices()
+7 -7
View File
@@ -555,17 +555,17 @@ func printBanner(services []*serviceProcess, gw *gateway.Gateway, watching bool,
fmt.Println()
if gw != nil {
fmt.Printf(" Dashboard \033[36mhttp://localhost%s\033[0m\n", gw.Addr())
fmt.Printf(" API \033[36mhttp://localhost%s/api/{service}/{method}\033[0m\n", gw.Addr())
fmt.Printf(" Agent \033[36mhttp://localhost%s/agent\033[0m\n", gw.Addr())
fmt.Printf(" Dashboard \033[36mhttp://%s\033[0m\n", gw.Addr())
fmt.Printf(" API \033[36mhttp://%s/api/{service}/{method}\033[0m\n", gw.Addr())
fmt.Printf(" Agent \033[36mhttp://%s/agent\033[0m\n", gw.Addr())
// MCP tools are served on the gateway by default — every endpoint is an
// AI-callable tool, so surface it rather than hiding it behind a flag.
fmt.Printf(" MCP Tools \033[36mhttp://localhost%s/mcp/tools\033[0m\n", gw.Addr())
fmt.Printf(" Health \033[36mhttp://localhost%s/health\033[0m\n", gw.Addr())
fmt.Printf(" MCP Tools \033[36mhttp://%s/mcp/tools\033[0m\n", gw.Addr())
fmt.Printf(" Health \033[36mhttp://%s/health\033[0m\n", gw.Addr())
if mcpAddr != "" {
// Optional standalone MCP protocol server (e.g. for MCP clients).
fmt.Printf(" MCP Server \033[36mhttp://localhost%s\033[0m (full MCP protocol)\n", mcpAddr)
fmt.Printf(" WebSocket \033[36mws://localhost%s/mcp/ws\033[0m\n", mcpAddr)
fmt.Printf(" MCP Server \033[36mhttp://%s\033[0m (full MCP protocol)\n", mcpAddr)
fmt.Printf(" WebSocket \033[36mws://%s/mcp/ws\033[0m\n", mcpAddr)
}
}
+2 -2
View File
@@ -59,7 +59,7 @@ func fileTest(s Store, t *testing.T) {
if err := s.Write(&Record{
Key: "Hello",
Value: []byte("World"),
Expiry: time.Millisecond * 150,
Expiry: time.Second, // wide window: bbolt fsync + -race/-cover reads on a loaded runner can exceed 150ms
}); err != nil {
t.Error(err)
}
@@ -79,7 +79,7 @@ func fileTest(s Store, t *testing.T) {
}
// wait for expiry
time.Sleep(time.Millisecond * 200)
time.Sleep(time.Second * 2)
if _, err := s.Read("Hello"); err != ErrNotFound {
t.Errorf("Expected %# v, got %# v", ErrNotFound, err)