feat(server/admin): add get resource endpoint

This commit is contained in:
Yuan Teoh
2025-08-28 16:37:27 -07:00
parent 9bc91a6cd8
commit cd716a725a
9 changed files with 387 additions and 49 deletions

View File

@@ -15,6 +15,9 @@
package server
import (
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/render"
@@ -28,5 +31,44 @@ func adminRouter(s *Server) (chi.Router, error) {
r.Use(middleware.StripSlashes)
r.Use(render.SetContentType(render.ContentTypeJSON))
r.Get("/{resource}", func(w http.ResponseWriter, r *http.Request) { adminGetHandler(s, w, r) })
return r, nil
}
// adminGetHandler handles requests for a list of specific resource
func adminGetHandler(s *Server, w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
resource := chi.URLParam(r, "resource")
var resourceList []string
switch resource {
case "source":
sourcesMap := s.ResourceMgr.GetSourcesMap()
for n := range sourcesMap {
resourceList = append(resourceList, n)
}
case "authservice":
authServicesMap := s.ResourceMgr.GetAuthServiceMap()
for n := range authServicesMap {
resourceList = append(resourceList, n)
}
case "tool":
toolsMap := s.ResourceMgr.GetToolsMap()
for n := range toolsMap {
resourceList = append(resourceList, n)
}
case "toolset":
toolsetsMap := s.ResourceMgr.GetToolsetsMap()
for n := range toolsetsMap {
resourceList = append(resourceList, n)
}
default:
err := fmt.Errorf(`invalid resource %s, please provide one of "source", "authservice", "tool", or "toolset"`, resource)
s.logger.DebugContext(ctx, err.Error())
_ = render.Render(w, r, newErrResponse(err, http.StatusNotFound))
return
}
render.JSON(w, r, resourceList)
}

View File

@@ -0,0 +1,222 @@
// Copyright 2025 Google LLC
//
// 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 server
import (
"reflect"
"encoding/json"
"context"
"net/http"
"testing"
"slices"
"github.com/googleapis/genai-toolbox/internal/auth"
"github.com/googleapis/genai-toolbox/internal/sources"
"go.opentelemetry.io/otel/trace"
)
var _ sources.Source = &MockSource{}
var _ sources.SourceConfig = &MockSourceConfig{}
// MockSource is used to mock sources in tests
type MockSource struct {
name string
kind string
}
func (s MockSource) SourceKind() string {
return s.kind
}
type MockSourceConfig struct {
Name string `yaml:"name"`
Kind string `yaml:"kind"`
Project string `yaml:"project"`
User string `yaml:"user"`
Password string `yaml:"password"`
Database string `yaml:"database"`
}
func (sc MockSourceConfig) SourceConfigKind() string {
return "mock-source"
}
func (sc MockSourceConfig) Initialize(ctx context.Context, tracer trace.Tracer) (sources.Source, error) {
s := MockSource{
name: sc.Name,
kind: sc.Kind,
}
return s, nil
}
var sourceConfig1 = MockSourceConfig{
Name: "source1",
Kind: "mock-source",
Project: "my-project",
User: "my-user",
Password: "my-password",
Database: "my-db",
}
type MockAuthService struct {
name string
kind string
clientID string
}
func (as MockAuthService) AuthServiceKind() string {
return as.kind
}
func (as MockAuthService) GetName() string {
return as.name
}
func (as MockAuthService) GetClaimsFromHeader(context.Context, http.Header) (map[string]any, error) {
return map[string]any{"foo": "bar"}, nil
}
type MockASConfig struct {
Name string `yaml:"name"`
Kind string `yaml:"kind"`
ClientID string `yaml:"clientId"`
}
func (ac MockASConfig) AuthServiceConfigKind() string {
return "mock-auth-service"
}
func (ac MockASConfig) Initialize() (auth.AuthService, error) {
a := MockAuthService{
name: ac.Name,
kind: ac.Kind,
clientID: ac.ClientID,
}
return a, nil
}
var authService1 = MockASConfig{
Name: "auth-service1",
Kind: "mock-auth-service",
ClientID: "foo",
}
func TestAdminGetResourceEndpoint(t *testing.T) {
source1, _ := sourceConfig1.Initialize(context.Background(), nil)
mockSources := []MockSource{source1.(MockSource)}
as1, _ := authService1.Initialize()
mockAuthServices := []MockAuthService{as1.(MockAuthService)}
mockTools := []MockTool{tool1, tool2}
sourcesMap, authServicesMap, toolsMap, toolsets := setUpResources(t, mockSources, mockAuthServices, mockTools)
r, shutdown := setUpServer(t, "admin", sourcesMap, authServicesMap, toolsMap, toolsets)
defer shutdown()
ts := runServer(r, false)
defer ts.Close()
// wantResponse is a struct for checks against test cases
type wantResponse struct {
statusCode int
isErr bool
errString string
resourcesList []string
}
testCases := []struct {
name string
url string
want wantResponse
}{
{
name: "get source",
url: "/source",
want: wantResponse{
statusCode: http.StatusOK,
resourcesList: []string{"source1"},
},
},
{
name: "get auth services",
url: "/authservice",
want: wantResponse{
statusCode: http.StatusOK,
resourcesList: []string{"auth-service1"},
},
},
{
name: "get tool",
url: "/tool",
want: wantResponse{
statusCode: http.StatusOK,
resourcesList: []string{"no_params", "some_params"},
},
},
{
name: "get toolset",
url: "/toolset",
want: wantResponse{
statusCode: http.StatusOK,
resourcesList: []string{"", "tool1_only", "tool2_only"},
},
},
{
name: "get invalid",
url: "/invalid",
want: wantResponse{
statusCode: http.StatusNotFound,
isErr: true,
errString: `invalid resource invalid, please provide one of "source", "authservice", "tool", or "toolset"`,
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
resp, body, err := runRequest(ts, http.MethodGet, tc.url, nil, nil)
if err != nil {
t.Fatalf("unexpected error during request: %s", err)
}
if contentType := resp.Header.Get("Content-type"); contentType != "application/json" {
t.Fatalf("unexpected content-type header: want %s, got %s", "application/json", contentType)
}
if tc.want.statusCode != resp.StatusCode {
t.Fatalf("unexpected status code: want %d, got %d", tc.want.statusCode, resp.StatusCode)
}
if tc.want.isErr {
var res errResponse
err = json.Unmarshal(body, &res)
if err != nil {
t.Fatalf("error unmarshaling body: %s", err)
}
if tc.want.errString != res.ErrorText {
t.Fatalf("unexpected error message: want %s, got %s", tc.want.errString, res.ErrorText)
}
return
}
var res []string
err = json.Unmarshal(body, &res)
if err != nil {
t.Fatalf("error unmarshaling body: %s", err)
}
slices.Sort(res)
if !reflect.DeepEqual(tc.want.resourcesList, res) {
t.Fatalf("unexpected response: want %+v, got %+v", tc.want.resourcesList, res)
}
})
}
}

View File

@@ -297,29 +297,3 @@ func (rr resultResponse) Render(w http.ResponseWriter, r *http.Request) error {
return nil
}
var _ render.Renderer = &errResponse{} // Renderer interface for managing response payloads.
// newErrResponse is a helper function initializing an ErrResponse
func newErrResponse(err error, code int) *errResponse {
return &errResponse{
Err: err,
HTTPStatusCode: code,
StatusText: http.StatusText(code),
ErrorText: err.Error(),
}
}
// errResponse is the response sent back when an error has been encountered.
type errResponse struct {
Err error `json:"-"` // low-level runtime error
HTTPStatusCode int `json:"-"` // http response status code
StatusText string `json:"status"` // user-level status message
ErrorText string `json:"error,omitempty"` // application-level error message, for debugging
}
func (e *errResponse) Render(w http.ResponseWriter, r *http.Request) error {
render.Status(r, e.HTTPStatusCode)
return nil
}

View File

@@ -28,8 +28,8 @@ import (
func TestToolsetEndpoint(t *testing.T) {
mockTools := []MockTool{tool1, tool2}
toolsMap, toolsets := setUpResources(t, mockTools)
r, shutdown := setUpServer(t, "api", toolsMap, toolsets)
sourcesMap, asMap, toolsMap, toolsets := setUpResources(t, nil, nil, mockTools)
r, shutdown := setUpServer(t, "api", sourcesMap, asMap, toolsMap, toolsets)
defer shutdown()
ts := runServer(r, false)
defer ts.Close()
@@ -125,8 +125,8 @@ func TestToolsetEndpoint(t *testing.T) {
func TestToolGetEndpoint(t *testing.T) {
mockTools := []MockTool{tool1, tool2}
toolsMap, toolsets := setUpResources(t, mockTools)
r, shutdown := setUpServer(t, "api", toolsMap, toolsets)
sourcesMap, asMap, toolsMap, toolsets := setUpResources(t, nil, nil, mockTools)
r, shutdown := setUpServer(t, "api", sourcesMap, asMap, toolsMap, toolsets)
defer shutdown()
ts := runServer(r, false)
defer ts.Close()
@@ -213,8 +213,8 @@ func TestToolGetEndpoint(t *testing.T) {
func TestToolInvokeEndpoint(t *testing.T) {
mockTools := []MockTool{tool1, tool2, tool4, tool5}
toolsMap, toolsets := setUpResources(t, mockTools)
r, shutdown := setUpServer(t, "api", toolsMap, toolsets)
sourcesMap, asMap, toolsMap, toolsets := setUpResources(t, nil, nil, mockTools)
r, shutdown := setUpServer(t, "api", sourcesMap, asMap, toolsMap, toolsets)
defer shutdown()
ts := runServer(r, false)
defer ts.Close()

View File

@@ -24,7 +24,9 @@ import (
"testing"
"github.com/go-chi/chi/v5"
"github.com/googleapis/genai-toolbox/internal/auth"
"github.com/googleapis/genai-toolbox/internal/log"
"github.com/googleapis/genai-toolbox/internal/sources"
"github.com/googleapis/genai-toolbox/internal/telemetry"
"github.com/googleapis/genai-toolbox/internal/tools"
)
@@ -129,7 +131,17 @@ var tool5 = MockTool{
}
// setUpResources setups resources to test against
func setUpResources(t *testing.T, mockTools []MockTool) (map[string]tools.Tool, map[string]tools.Toolset) {
func setUpResources(t *testing.T, mockSources []MockSource, mockAuthServices []MockAuthService, mockTools []MockTool) (map[string]sources.Source, map[string]auth.AuthService, map[string]tools.Tool, map[string]tools.Toolset) {
sourcesMap := make(map[string]sources.Source)
for _, s := range mockSources {
sourcesMap[s.name] = s
}
authServicesMap := make(map[string]auth.AuthService)
for _, a := range mockAuthServices {
authServicesMap[a.name] = a
}
toolsMap := make(map[string]tools.Tool)
var allTools []string
for _, tool := range mockTools {
@@ -151,11 +163,11 @@ func setUpResources(t *testing.T, mockTools []MockTool) (map[string]tools.Tool,
}
toolsets[name] = m
}
return toolsMap, toolsets
return sourcesMap, authServicesMap, toolsMap, toolsets
}
// setUpServer create a new server with tools and toolsets that are given
func setUpServer(t *testing.T, router string, tools map[string]tools.Tool, toolsets map[string]tools.Toolset) (chi.Router, func()) {
func setUpServer(t *testing.T, router string, sources map[string]sources.Source, authServices map[string]auth.AuthService, tools map[string]tools.Tool, toolsets map[string]tools.Toolset) (chi.Router, func()) {
ctx, cancel := context.WithCancel(context.Background())
testLogger, err := log.NewStdLogger(os.Stdout, os.Stderr, "info")
@@ -175,7 +187,7 @@ func setUpServer(t *testing.T, router string, tools map[string]tools.Tool, tools
sseManager := newSseManager(ctx)
resourceManager := NewResourceManager(nil, nil, tools, toolsets)
resourceManager := NewResourceManager(sources, authServices, tools, toolsets)
server := Server{
version: fakeVersionString,
@@ -197,6 +209,11 @@ func setUpServer(t *testing.T, router string, tools map[string]tools.Tool, tools
if err != nil {
t.Fatalf("unable to initialize mcp router: %s", err)
}
case "admin":
r, err = adminRouter(&server)
if err != nil {
t.Fatalf("unable to initialize admin router: %s", err)
}
default:
t.Fatalf("unknown router")
}

View File

@@ -27,8 +27,10 @@ import (
"strings"
"testing"
"github.com/googleapis/genai-toolbox/internal/auth"
"github.com/googleapis/genai-toolbox/internal/log"
"github.com/googleapis/genai-toolbox/internal/server/mcp/jsonrpc"
"github.com/googleapis/genai-toolbox/internal/sources"
"github.com/googleapis/genai-toolbox/internal/telemetry"
"github.com/googleapis/genai-toolbox/internal/tools"
)
@@ -68,8 +70,8 @@ var tool3InputSchema = map[string]any{
func TestMcpEndpointWithoutInitialized(t *testing.T) {
mockTools := []MockTool{tool1, tool2, tool3, tool4, tool5}
toolsMap, toolsets := setUpResources(t, mockTools)
r, shutdown := setUpServer(t, "mcp", toolsMap, toolsets)
sourcesMap, asMap, toolsMap, toolsets := setUpResources(t, nil, nil, mockTools)
r, shutdown := setUpServer(t, "mcp", sourcesMap, asMap, toolsMap, toolsets)
defer shutdown()
ts := runServer(r, false)
defer ts.Close()
@@ -337,8 +339,8 @@ func runInitializeLifecycle(t *testing.T, ts *httptest.Server, protocolVersion s
func TestMcpEndpoint(t *testing.T) {
mockTools := []MockTool{tool1, tool2, tool3, tool4, tool5}
toolsMap, toolsets := setUpResources(t, mockTools)
r, shutdown := setUpServer(t, "mcp", toolsMap, toolsets)
sourcesMap, asMap, toolsMap, toolsets := setUpResources(t, nil, nil, mockTools)
r, shutdown := setUpServer(t, "mcp", sourcesMap, asMap, toolsMap, toolsets)
defer shutdown()
ts := runServer(r, false)
defer ts.Close()
@@ -743,8 +745,8 @@ func TestMcpEndpoint(t *testing.T) {
}
func TestInvalidProtocolVersionHeader(t *testing.T) {
toolsMap, toolsets := map[string]tools.Tool{}, map[string]tools.Toolset{}
r, shutdown := setUpServer(t, "mcp", toolsMap, toolsets)
sourcesMap, asMap, toolsMap, toolsets := map[string]sources.Source{}, map[string]auth.AuthService{}, map[string]tools.Tool{}, map[string]tools.Toolset{}
r, shutdown := setUpServer(t, "mcp", sourcesMap, asMap, toolsMap, toolsets)
defer shutdown()
ts := runServer(r, false)
defer ts.Close()
@@ -770,8 +772,8 @@ func TestInvalidProtocolVersionHeader(t *testing.T) {
}
func TestDeleteEndpoint(t *testing.T) {
toolsMap, toolsets := map[string]tools.Tool{}, map[string]tools.Toolset{}
r, shutdown := setUpServer(t, "mcp", toolsMap, toolsets)
sourcesMap, asMap, toolsMap, toolsets := map[string]sources.Source{}, map[string]auth.AuthService{}, map[string]tools.Tool{}, map[string]tools.Toolset{}
r, shutdown := setUpServer(t, "mcp", sourcesMap, asMap, toolsMap, toolsets)
defer shutdown()
ts := runServer(r, false)
defer ts.Close()
@@ -786,8 +788,8 @@ func TestDeleteEndpoint(t *testing.T) {
}
func TestGetEndpoint(t *testing.T) {
toolsMap, toolsets := map[string]tools.Tool{}, map[string]tools.Toolset{}
r, shutdown := setUpServer(t, "mcp", toolsMap, toolsets)
sourcesMap, asMap, toolsMap, toolsets := map[string]sources.Source{}, map[string]auth.AuthService{}, map[string]tools.Tool{}, map[string]tools.Toolset{}
r, shutdown := setUpServer(t, "mcp", sourcesMap, asMap, toolsMap, toolsets)
defer shutdown()
ts := runServer(r, false)
defer ts.Close()
@@ -810,7 +812,7 @@ func TestGetEndpoint(t *testing.T) {
}
func TestSseEndpoint(t *testing.T) {
r, shutdown := setUpServer(t, "mcp", nil, nil)
r, shutdown := setUpServer(t, "mcp", nil, nil, nil, nil)
defer shutdown()
ts := runServer(r, false)
defer ts.Close()
@@ -925,7 +927,7 @@ func TestStdioSession(t *testing.T) {
defer cancel()
mockTools := []MockTool{tool1, tool2, tool3}
toolsMap, toolsets := setUpResources(t, mockTools)
sourcesMap, asMap, toolsMap, toolsets := setUpResources(t, nil, nil, mockTools)
pr, pw, err := os.Pipe()
if err != nil {
@@ -955,7 +957,7 @@ func TestStdioSession(t *testing.T) {
sseManager := newSseManager(ctx)
resourceManager := NewResourceManager(nil, nil, toolsMap, toolsets)
resourceManager := NewResourceManager(sourcesMap, asMap, toolsMap, toolsets)
server := &Server{
version: fakeVersionString,

View File

@@ -111,6 +111,12 @@ func (r *ResourceManager) SetResources(sourcesMap map[string]sources.Source, aut
r.toolsets = toolsetsMap
}
func (r *ResourceManager) GetSourcesMap() map[string]sources.Source {
r.mu.RLock()
defer r.mu.RUnlock()
return r.sources
}
func (r *ResourceManager) GetAuthServiceMap() map[string]auth.AuthService {
r.mu.RLock()
defer r.mu.RUnlock()
@@ -123,6 +129,12 @@ func (r *ResourceManager) GetToolsMap() map[string]tools.Tool {
return r.tools
}
func (r *ResourceManager) GetToolsetsMap() map[string]tools.Toolset {
r.mu.RLock()
defer r.mu.RUnlock()
return r.toolsets
}
func InitializeConfigs(ctx context.Context, cfg ServerConfig) (
map[string]sources.Source,
map[string]auth.AuthService,

View File

@@ -20,6 +20,7 @@ import (
"io"
"net/http"
"os"
"reflect"
"strings"
"testing"
@@ -152,6 +153,26 @@ func TestUpdateServer(t *testing.T) {
t.Errorf("error updating server: %s", err)
}
gotSourcesMap := s.ResourceMgr.GetSourcesMap()
if !reflect.DeepEqual(gotSourcesMap, newSources) {
t.Errorf("error retrieving sources map: got %+v, want %+v", gotSourcesMap, newSources)
}
gotAuthServiceMap := s.ResourceMgr.GetAuthServiceMap()
if !reflect.DeepEqual(gotAuthServiceMap, newAuth) {
t.Errorf("error retrieving auth servies map: got %+v, want %+v", gotAuthServiceMap, newAuth)
}
gotToolsMap := s.ResourceMgr.GetToolsMap()
if !reflect.DeepEqual(gotToolsMap, newTools) {
t.Errorf("error retrieving tools map: got %+v, want %+v", gotToolsMap, newTools)
}
gotToolsetsMap := s.ResourceMgr.GetToolsetsMap()
if !reflect.DeepEqual(gotToolsetsMap, newToolsets) {
t.Errorf("error retrieving toolsets map: got %+v, want %+v", gotToolsetsMap, newToolsets)
}
gotSource, _ := s.ResourceMgr.GetSource("example-source")
if diff := cmp.Diff(gotSource, newSources["example-source"]); diff != "" {
t.Errorf("error updating server, sources (-want +got):\n%s", diff)

48
internal/server/util.go Normal file
View File

@@ -0,0 +1,48 @@
// Copyright 2025 Google LLC
//
// 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 server
import (
"net/http"
"github.com/go-chi/render"
)
var _ render.Renderer = &errResponse{} // Renderer interface for managing response payloads.
// newErrResponse is a helper function initializing an ErrResponse
func newErrResponse(err error, code int) *errResponse {
return &errResponse{
Err: err,
HTTPStatusCode: code,
StatusText: http.StatusText(code),
ErrorText: err.Error(),
}
}
// errResponse is the response sent back when an error has been encountered.
type errResponse struct {
Err error `json:"-"` // low-level runtime error
HTTPStatusCode int `json:"-"` // http response status code
StatusText string `json:"status"` // user-level status message
ErrorText string `json:"error,omitempty"` // application-level error message, for debugging
}
func (e *errResponse) Render(w http.ResponseWriter, r *http.Request) error {
render.Status(r, e.HTTPStatusCode)
return nil
}