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:
committed by
GitHub
parent
b0d4ac7e01
commit
a6d4272e21
+1
-1
@@ -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/
|
||||
|
||||
@@ -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>
|
||||
|
||||
<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>
|
||||
|
||||
<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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user