feat(mcp): add resource registry and dispatcher
Define the Op bitmask, the Resource struct, the package-level Register function, and the Dispatch entry point that future tasks will use to expose CRUD resources over MCP. No resources are registered yet. Op carries the CRUD-op identity, knows its api-token permission string (matching apiTokenRoutes exactly), and knows its tool-name suffix. Resource.Inputs maps each enabled op to a pointer-to-zero of the wrapper type the dispatcher will allocate and unmarshal into. Register validates the resource shape and populates a tool-name lookup table so the dispatcher never has to string-parse names like task_comments_read_all. Dispatch threads the user from ctx, allocates a fresh wrapper, unmarshals arguments, asks the wrapper to copy itself onto a fresh model via the inputAdapter seam (which Task 4 will populate with real implementations), and forwards to the corresponding handler.Do* function. The Do* calls go through a swappable crudFuncs struct so the unit tests can verify dispatch routing without standing up the database.
This commit is contained in:
parent
3ec2d89543
commit
a0116749d1
|
|
@ -0,0 +1,193 @@
|
||||||
|
// Vikunja is a to-do list application to facilitate your life.
|
||||||
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
|
||||||
|
"code.vikunja.io/api/pkg/web"
|
||||||
|
"code.vikunja.io/api/pkg/web/handler"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrToolNotFound is returned when Dispatch is called for a tool name that
|
||||||
|
// has not been registered. Callers should map this to an MCP tool result
|
||||||
|
// with IsError=true (per the SDK convention for missing tools), not to a
|
||||||
|
// JSON-RPC protocol error.
|
||||||
|
var ErrToolNotFound = errors.New("mcp: tool not found")
|
||||||
|
|
||||||
|
// ErrNoUserInContext is returned when Dispatch is invoked without a user
|
||||||
|
// in ctx. Task 2's entry handler always sets one, so hitting this means
|
||||||
|
// either a programming bug or someone calling Dispatch outside the HTTP
|
||||||
|
// pipeline.
|
||||||
|
var ErrNoUserInContext = errors.New("mcp: no user in context")
|
||||||
|
|
||||||
|
// inputAdapter is the Task 3/Task 4 seam. Each per-op input wrapper struct
|
||||||
|
// (defined in inputs.go, added by Task 4) implements ApplyTo, which copies
|
||||||
|
// the wrapper's fields onto a fresh handler.CObject. The dispatcher
|
||||||
|
// allocates a wrapper from Resource.Inputs[op] via reflection,
|
||||||
|
// json.Unmarshals tool arguments into it, then calls ApplyTo on the model
|
||||||
|
// returned by Resource.EmptyStruct().
|
||||||
|
//
|
||||||
|
// Defining the interface here (rather than in inputs.go) keeps the
|
||||||
|
// dispatcher buildable in Task 3 before any wrappers exist; the
|
||||||
|
// dispatcher tests provide their own ApplyTo implementation to exercise
|
||||||
|
// the code path.
|
||||||
|
type inputAdapter interface {
|
||||||
|
ApplyTo(dst handler.CObject) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// readAllInput is the optional interface a wrapper for OpReadAll may
|
||||||
|
// implement to expose pagination fields to the dispatcher. Wrappers that
|
||||||
|
// don't implement it get search="", page=0, perPage=0 (the same defaults
|
||||||
|
// the REST layer applies when callers omit the query parameters).
|
||||||
|
type readAllInput interface {
|
||||||
|
ReadAllParams() (search string, page int, perPage int)
|
||||||
|
}
|
||||||
|
|
||||||
|
// crudFuncs are the framework-agnostic Do* entry points the dispatcher
|
||||||
|
// invokes. The package-level defaults point at handler.Do*; tests swap
|
||||||
|
// them out so they can run without a database connection (handler.Do*
|
||||||
|
// opens an xorm session, which is fine in integration tests but not in
|
||||||
|
// the dispatcher unit tests that exercise routing logic only).
|
||||||
|
type crudFuncs struct {
|
||||||
|
doCreate func(context.Context, handler.CObject, web.Auth) error
|
||||||
|
doReadOne func(context.Context, handler.CObject, web.Auth) (int, error)
|
||||||
|
doReadAll func(context.Context, handler.CObject, web.Auth, string, int, int) (any, int, int64, error)
|
||||||
|
doUpdate func(context.Context, handler.CObject, web.Auth) error
|
||||||
|
doDelete func(context.Context, handler.CObject, web.Auth) error
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultCRUD = crudFuncs{
|
||||||
|
doCreate: handler.DoCreate,
|
||||||
|
doReadOne: handler.DoReadOne,
|
||||||
|
doReadAll: handler.DoReadAll,
|
||||||
|
doUpdate: handler.DoUpdate,
|
||||||
|
doDelete: handler.DoDelete,
|
||||||
|
}
|
||||||
|
|
||||||
|
// crud is the live set of Do* functions Dispatch uses. Tests swap it out
|
||||||
|
// via withCRUD and restore it on teardown.
|
||||||
|
var crud = defaultCRUD
|
||||||
|
|
||||||
|
// Dispatch is the single entry point for every tools/call. It returns
|
||||||
|
// either the result the SDK should serialize (a model on read_one/update,
|
||||||
|
// the slice from ReadAll on read_all, or the model on create) or an error.
|
||||||
|
//
|
||||||
|
// Errors fall into two categories:
|
||||||
|
// - ErrToolNotFound / ErrNoUserInContext / JSON-unmarshal errors are
|
||||||
|
// dispatcher-level failures the caller should translate into an
|
||||||
|
// IsError=true tool result. We return them as errors here (rather than
|
||||||
|
// constructing a *mcp.CallToolResult) so the dispatcher stays
|
||||||
|
// SDK-agnostic; the thin AddTool handler in Task 5 does the wrapping.
|
||||||
|
// - Errors returned by handler.Do* (model-layer permission denials,
|
||||||
|
// validation failures, etc.) are propagated as-is. The tool handler
|
||||||
|
// in Task 5 wraps them with SetError per the SDK's convention that
|
||||||
|
// domain failures be reported as tool results, not protocol errors.
|
||||||
|
func Dispatch(ctx context.Context, toolName string, rawArgs json.RawMessage) (any, error) {
|
||||||
|
ref, ok := lookupTool(toolName)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrToolNotFound, toolName)
|
||||||
|
}
|
||||||
|
|
||||||
|
u := UserFromContext(ctx)
|
||||||
|
if u == nil {
|
||||||
|
return nil, ErrNoUserInContext
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate a fresh wrapper for this call so concurrent dispatches
|
||||||
|
// don't share state through the prototype stored in r.Inputs.
|
||||||
|
wrapperProto, ok := ref.resource.Inputs[ref.op]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("mcp: resource %q has no input wrapper for op %s", ref.resource.Name, ref.op.ToolSuffix())
|
||||||
|
}
|
||||||
|
wrapper, err := allocateWrapper(wrapperProto)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rawArgs) > 0 {
|
||||||
|
if err := json.Unmarshal(rawArgs, wrapper); err != nil {
|
||||||
|
return nil, fmt.Errorf("mcp: invalid arguments for %s: %w", toolName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
model := ref.resource.EmptyStruct()
|
||||||
|
if adapter, ok := wrapper.(inputAdapter); ok {
|
||||||
|
if err := adapter.ApplyTo(model); err != nil {
|
||||||
|
return nil, fmt.Errorf("mcp: copy input for %s: %w", toolName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ref.op {
|
||||||
|
case OpCreate:
|
||||||
|
if err := crud.doCreate(ctx, model, u); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return model, nil
|
||||||
|
|
||||||
|
case OpReadOne:
|
||||||
|
if _, err := crud.doReadOne(ctx, model, u); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return model, nil
|
||||||
|
|
||||||
|
case OpReadAll:
|
||||||
|
search, page, perPage := "", 0, 0
|
||||||
|
if ra, ok := wrapper.(readAllInput); ok {
|
||||||
|
search, page, perPage = ra.ReadAllParams()
|
||||||
|
}
|
||||||
|
result, _, _, err := crud.doReadAll(ctx, model, u, search, page, perPage)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
|
||||||
|
case OpUpdate:
|
||||||
|
if err := crud.doUpdate(ctx, model, u); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return model, nil
|
||||||
|
|
||||||
|
case OpDelete:
|
||||||
|
if err := crud.doDelete(ctx, model, u); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return model, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("mcp: unsupported op %d for tool %s", ref.op, toolName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// allocateWrapper returns a fresh pointer of the same concrete type as the
|
||||||
|
// prototype stored in Resource.Inputs. Resource.Inputs is conventionally a
|
||||||
|
// pointer-to-zero (e.g. &ProjectCreateInput{}); allocateWrapper takes its
|
||||||
|
// reflect.Type, allocates a fresh value, and hands back a pointer suitable
|
||||||
|
// for json.Unmarshal.
|
||||||
|
func allocateWrapper(proto any) (any, error) {
|
||||||
|
if proto == nil {
|
||||||
|
return nil, errors.New("mcp: nil input prototype")
|
||||||
|
}
|
||||||
|
t := reflect.TypeOf(proto)
|
||||||
|
if t.Kind() != reflect.Pointer {
|
||||||
|
return nil, fmt.Errorf("mcp: input prototype must be a pointer, got %s", t.Kind())
|
||||||
|
}
|
||||||
|
return reflect.New(t.Elem()).Interface(), nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,319 @@
|
||||||
|
// Vikunja is a to-do list application to facilitate your life.
|
||||||
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.vikunja.io/api/pkg/user"
|
||||||
|
"code.vikunja.io/api/pkg/web"
|
||||||
|
"code.vikunja.io/api/pkg/web/handler"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"xorm.io/xorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stubCObject is a test double for handler.CObject that records which method
|
||||||
|
// was invoked by the dispatcher. Each instance must be checked individually,
|
||||||
|
// because handler.Do* runs against a fresh EmptyStruct() per call.
|
||||||
|
type stubCObject struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string
|
||||||
|
|
||||||
|
// called records the most recent CRUD method invoked on this instance.
|
||||||
|
called string
|
||||||
|
// returnErr is returned from the next CRUD method invoked. Permission
|
||||||
|
// checks always allow access; failure scenarios are exercised by the
|
||||||
|
// model layer in the integration tests.
|
||||||
|
returnErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubCObject) CanRead(_ *xorm.Session, _ web.Auth) (bool, int, error) {
|
||||||
|
return true, 0, nil
|
||||||
|
}
|
||||||
|
func (s *stubCObject) CanDelete(_ *xorm.Session, _ web.Auth) (bool, error) { return true, nil }
|
||||||
|
func (s *stubCObject) CanUpdate(_ *xorm.Session, _ web.Auth) (bool, error) { return true, nil }
|
||||||
|
func (s *stubCObject) CanCreate(_ *xorm.Session, _ web.Auth) (bool, error) { return true, nil }
|
||||||
|
|
||||||
|
func (s *stubCObject) Create(_ *xorm.Session, _ web.Auth) error {
|
||||||
|
s.called = "Create"
|
||||||
|
return s.returnErr
|
||||||
|
}
|
||||||
|
func (s *stubCObject) ReadOne(_ *xorm.Session, _ web.Auth) error {
|
||||||
|
s.called = "ReadOne"
|
||||||
|
return s.returnErr
|
||||||
|
}
|
||||||
|
func (s *stubCObject) ReadAll(_ *xorm.Session, _ web.Auth, search string, page, perPage int) (any, int, int64, error) {
|
||||||
|
s.called = "ReadAll"
|
||||||
|
return []string{search}, page, int64(perPage), s.returnErr
|
||||||
|
}
|
||||||
|
func (s *stubCObject) Update(_ *xorm.Session, _ web.Auth) error {
|
||||||
|
s.called = "Update"
|
||||||
|
return s.returnErr
|
||||||
|
}
|
||||||
|
func (s *stubCObject) Delete(_ *xorm.Session, _ web.Auth) error {
|
||||||
|
s.called = "Delete"
|
||||||
|
return s.returnErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubTracker tracks the *last* instance handed out by EmptyStruct so the
|
||||||
|
// test can inspect which method was invoked after the dispatcher has run.
|
||||||
|
type stubTracker struct {
|
||||||
|
last *stubCObject
|
||||||
|
nextErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubTracker) empty() handler.CObject {
|
||||||
|
o := &stubCObject{returnErr: s.nextErr}
|
||||||
|
s.last = o
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubInput is the wrapper type used by the dispatcher tests for every op.
|
||||||
|
// In the real registry each op has its own wrapper type; for testing the
|
||||||
|
// dispatcher we only need something that unmarshal+ApplyTo work against.
|
||||||
|
type stubInput struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Search string `json:"search,omitempty"`
|
||||||
|
Page int `json:"page,omitempty"`
|
||||||
|
PerPage int `json:"per_page,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyTo copies wrapper fields onto the model. This is the seam Task 4 will
|
||||||
|
// fill in for real resources; for now the dispatcher tests provide their own
|
||||||
|
// implementation via the inputAdapter interface so we can verify dispatch
|
||||||
|
// without depending on the (still-absent) per-resource adapter.
|
||||||
|
func (i *stubInput) ApplyTo(dst handler.CObject) error {
|
||||||
|
s, ok := dst.(*stubCObject)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("stubInput: unexpected target type")
|
||||||
|
}
|
||||||
|
s.ID = i.ID
|
||||||
|
s.Title = i.Title
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadAllParams exposes the pagination fields to the dispatcher. The real
|
||||||
|
// wrappers in Task 4 follow the same shape; the dispatcher reads these
|
||||||
|
// without depending on the concrete struct.
|
||||||
|
func (i *stubInput) ReadAllParams() (string, int, int) {
|
||||||
|
return i.Search, i.Page, i.PerPage
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAuthedCtx(t *testing.T) context.Context {
|
||||||
|
t.Helper()
|
||||||
|
u := &user.User{ID: 42}
|
||||||
|
return WithUser(context.Background(), u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// installStubCRUD swaps the dispatcher's Do* function set with test doubles
|
||||||
|
// that drive the model's CRUD methods directly (no xorm session). It
|
||||||
|
// returns a teardown that restores the original handler.Do* set. Tests
|
||||||
|
// that need to verify dispatch routing without standing up the DB should
|
||||||
|
// call this at the top.
|
||||||
|
func installStubCRUD(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
saved := crud
|
||||||
|
crud = crudFuncs{
|
||||||
|
doCreate: func(_ context.Context, obj handler.CObject, a web.Auth) error {
|
||||||
|
return obj.Create(nil, a)
|
||||||
|
},
|
||||||
|
doReadOne: func(_ context.Context, obj handler.CObject, a web.Auth) (int, error) {
|
||||||
|
return 0, obj.ReadOne(nil, a)
|
||||||
|
},
|
||||||
|
doReadAll: func(_ context.Context, obj handler.CObject, a web.Auth, search string, page, perPage int) (any, int, int64, error) {
|
||||||
|
return obj.ReadAll(nil, a, search, page, perPage)
|
||||||
|
},
|
||||||
|
doUpdate: func(_ context.Context, obj handler.CObject, a web.Auth) error {
|
||||||
|
return obj.Update(nil, a)
|
||||||
|
},
|
||||||
|
doDelete: func(_ context.Context, obj handler.CObject, a web.Auth) error {
|
||||||
|
return obj.Delete(nil, a)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { crud = saved })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatchToolNotFound(t *testing.T) {
|
||||||
|
resetRegistry(t)
|
||||||
|
|
||||||
|
_, err := Dispatch(newAuthedCtx(t), "missing_tool", json.RawMessage(`{}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, ErrToolNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatchNoUser(t *testing.T) {
|
||||||
|
resetRegistry(t)
|
||||||
|
tracker := &stubTracker{}
|
||||||
|
require.NoError(t, Register(Resource{
|
||||||
|
Name: "stubs",
|
||||||
|
EmptyStruct: tracker.empty,
|
||||||
|
Ops: OpReadOne,
|
||||||
|
Inputs: map[Op]any{OpReadOne: &stubInput{}},
|
||||||
|
}))
|
||||||
|
|
||||||
|
_, err := Dispatch(context.Background(), "stubs_read_one", json.RawMessage(`{"id":1}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, ErrNoUserInContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatchCallsCreate(t *testing.T) {
|
||||||
|
resetRegistry(t)
|
||||||
|
installStubCRUD(t)
|
||||||
|
tracker := &stubTracker{}
|
||||||
|
require.NoError(t, Register(Resource{
|
||||||
|
Name: "stubs",
|
||||||
|
EmptyStruct: tracker.empty,
|
||||||
|
Ops: OpCreate,
|
||||||
|
Inputs: map[Op]any{OpCreate: &stubInput{}},
|
||||||
|
}))
|
||||||
|
|
||||||
|
_, err := Dispatch(newAuthedCtx(t), "stubs_create", json.RawMessage(`{"title":"hello"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, tracker.last)
|
||||||
|
assert.Equal(t, "Create", tracker.last.called)
|
||||||
|
assert.Equal(t, "hello", tracker.last.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatchCallsReadOne(t *testing.T) {
|
||||||
|
resetRegistry(t)
|
||||||
|
installStubCRUD(t)
|
||||||
|
tracker := &stubTracker{}
|
||||||
|
require.NoError(t, Register(Resource{
|
||||||
|
Name: "stubs",
|
||||||
|
EmptyStruct: tracker.empty,
|
||||||
|
Ops: OpReadOne,
|
||||||
|
Inputs: map[Op]any{OpReadOne: &stubInput{}},
|
||||||
|
}))
|
||||||
|
|
||||||
|
out, err := Dispatch(newAuthedCtx(t), "stubs_read_one", json.RawMessage(`{"id":7}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, tracker.last)
|
||||||
|
assert.Equal(t, "ReadOne", tracker.last.called)
|
||||||
|
assert.Equal(t, int64(7), tracker.last.ID)
|
||||||
|
// ReadOne returns the (now-populated) model directly.
|
||||||
|
assert.Same(t, tracker.last, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatchCallsReadAll(t *testing.T) {
|
||||||
|
resetRegistry(t)
|
||||||
|
installStubCRUD(t)
|
||||||
|
tracker := &stubTracker{}
|
||||||
|
require.NoError(t, Register(Resource{
|
||||||
|
Name: "stubs",
|
||||||
|
EmptyStruct: tracker.empty,
|
||||||
|
Ops: OpReadAll,
|
||||||
|
Inputs: map[Op]any{OpReadAll: &stubInput{}},
|
||||||
|
}))
|
||||||
|
|
||||||
|
out, err := Dispatch(newAuthedCtx(t), "stubs_read_all", json.RawMessage(`{"search":"foo","page":2,"per_page":50}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, tracker.last)
|
||||||
|
assert.Equal(t, "ReadAll", tracker.last.called)
|
||||||
|
// The stub's ReadAll echoes the search/page/per_page so we can confirm
|
||||||
|
// the dispatcher threaded the wrapper's pagination fields through.
|
||||||
|
assert.Equal(t, []string{"foo"}, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatchCallsUpdate(t *testing.T) {
|
||||||
|
resetRegistry(t)
|
||||||
|
installStubCRUD(t)
|
||||||
|
tracker := &stubTracker{}
|
||||||
|
require.NoError(t, Register(Resource{
|
||||||
|
Name: "stubs",
|
||||||
|
EmptyStruct: tracker.empty,
|
||||||
|
Ops: OpUpdate,
|
||||||
|
Inputs: map[Op]any{OpUpdate: &stubInput{}},
|
||||||
|
}))
|
||||||
|
|
||||||
|
_, err := Dispatch(newAuthedCtx(t), "stubs_update", json.RawMessage(`{"id":3,"title":"new"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, tracker.last)
|
||||||
|
assert.Equal(t, "Update", tracker.last.called)
|
||||||
|
assert.Equal(t, int64(3), tracker.last.ID)
|
||||||
|
assert.Equal(t, "new", tracker.last.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatchCallsDelete(t *testing.T) {
|
||||||
|
resetRegistry(t)
|
||||||
|
installStubCRUD(t)
|
||||||
|
tracker := &stubTracker{}
|
||||||
|
require.NoError(t, Register(Resource{
|
||||||
|
Name: "stubs",
|
||||||
|
EmptyStruct: tracker.empty,
|
||||||
|
Ops: OpDelete,
|
||||||
|
Inputs: map[Op]any{OpDelete: &stubInput{}},
|
||||||
|
}))
|
||||||
|
|
||||||
|
_, err := Dispatch(newAuthedCtx(t), "stubs_delete", json.RawMessage(`{"id":9}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, tracker.last)
|
||||||
|
assert.Equal(t, "Delete", tracker.last.called)
|
||||||
|
assert.Equal(t, int64(9), tracker.last.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatchModelErrorPropagates(t *testing.T) {
|
||||||
|
resetRegistry(t)
|
||||||
|
installStubCRUD(t)
|
||||||
|
wantErr := errors.New("simulated model error")
|
||||||
|
tracker := &stubTracker{nextErr: wantErr}
|
||||||
|
require.NoError(t, Register(Resource{
|
||||||
|
Name: "stubs",
|
||||||
|
EmptyStruct: tracker.empty,
|
||||||
|
Ops: OpReadOne,
|
||||||
|
Inputs: map[Op]any{OpReadOne: &stubInput{}},
|
||||||
|
}))
|
||||||
|
|
||||||
|
_, err := Dispatch(newAuthedCtx(t), "stubs_read_one", json.RawMessage(`{"id":1}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, wantErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatchInvalidJSON(t *testing.T) {
|
||||||
|
resetRegistry(t)
|
||||||
|
tracker := &stubTracker{}
|
||||||
|
require.NoError(t, Register(Resource{
|
||||||
|
Name: "stubs",
|
||||||
|
EmptyStruct: tracker.empty,
|
||||||
|
Ops: OpReadOne,
|
||||||
|
Inputs: map[Op]any{OpReadOne: &stubInput{}},
|
||||||
|
}))
|
||||||
|
|
||||||
|
_, err := Dispatch(newAuthedCtx(t), "stubs_read_one", json.RawMessage(`{not json`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatchUnsupportedOpForResource(t *testing.T) {
|
||||||
|
resetRegistry(t)
|
||||||
|
tracker := &stubTracker{}
|
||||||
|
require.NoError(t, Register(Resource{
|
||||||
|
Name: "stubs",
|
||||||
|
EmptyStruct: tracker.empty,
|
||||||
|
Ops: OpReadOne, // only read_one is registered
|
||||||
|
Inputs: map[Op]any{OpReadOne: &stubInput{}},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// stubs_create was never registered, so it must be tool-not-found.
|
||||||
|
_, err := Dispatch(newAuthedCtx(t), "stubs_create", json.RawMessage(`{}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, ErrToolNotFound)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
// Vikunja is a to-do list application to facilitate your life.
|
||||||
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"code.vikunja.io/api/pkg/web/handler"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Op is a bitmask of the CRUD operations a resource exposes. Bitmask was
|
||||||
|
// chosen because resources rarely need anything beyond a simple
|
||||||
|
// allow/disallow per op and OR-ing flags reads cleanly at the registration
|
||||||
|
// site (e.g. OpCreate | OpReadOne | OpReadAll). No other corner of the
|
||||||
|
// codebase uses bitmasks; this is local to the MCP registry.
|
||||||
|
type Op uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
OpCreate Op = 1 << iota
|
||||||
|
OpReadOne
|
||||||
|
OpReadAll
|
||||||
|
OpUpdate
|
||||||
|
OpDelete
|
||||||
|
)
|
||||||
|
|
||||||
|
// AllOps returns the ops in registration-and-iteration order. Keeping the
|
||||||
|
// list in one place ensures the registry, dispatcher, and any future
|
||||||
|
// tools/list filter walk the same five.
|
||||||
|
func AllOps() []Op {
|
||||||
|
return []Op{OpCreate, OpReadOne, OpReadAll, OpUpdate, OpDelete}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission returns the API-token permission string for this op. The
|
||||||
|
// strings must match the permission names that pkg/models/api_routes.go
|
||||||
|
// stores under apiTokenRoutes[group][...]; CanDoAPIRoute in the REST layer
|
||||||
|
// and the (future) MCP per-tool scope filter both look up by these exact
|
||||||
|
// strings.
|
||||||
|
func (o Op) Permission() string {
|
||||||
|
switch o {
|
||||||
|
case OpCreate:
|
||||||
|
return "create"
|
||||||
|
case OpReadOne:
|
||||||
|
return "read_one"
|
||||||
|
case OpReadAll:
|
||||||
|
return "read_all"
|
||||||
|
case OpUpdate:
|
||||||
|
return "update"
|
||||||
|
case OpDelete:
|
||||||
|
return "delete"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolSuffix returns the snake_case suffix used to form a tool name. Tool
|
||||||
|
// names are <resource.Name>_<op-suffix>; the suffix is identical to the
|
||||||
|
// permission string today but kept separate so the two can evolve
|
||||||
|
// independently if MCP and the REST scope system diverge.
|
||||||
|
func (o Op) ToolSuffix() string {
|
||||||
|
return o.Permission()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resource describes a CRUD-able model exposed over MCP. Mirrors the
|
||||||
|
// handler.WebHandler{EmptyStruct: ...} shape used in pkg/routes/routes.go.
|
||||||
|
//
|
||||||
|
// Inputs maps each enabled op to a pointer-to-zero of the wrapper struct
|
||||||
|
// the dispatcher should unmarshal tool arguments into. The wrapper carries
|
||||||
|
// json:/jsonschema: tags consumed by the SDK's AddTool for input-schema
|
||||||
|
// generation, and implements the inputAdapter seam below so the dispatcher
|
||||||
|
// can copy wrapper -> fresh model before invoking handler.Do*.
|
||||||
|
//
|
||||||
|
// The wrapper structs themselves live in inputs.go (introduced in Task 4).
|
||||||
|
// Task 3 only carries them through the registry.
|
||||||
|
type Resource struct {
|
||||||
|
// Name matches the API-token scope group exactly (e.g. "projects",
|
||||||
|
// "task_comments"). It is also the prefix of every tool name this
|
||||||
|
// resource produces.
|
||||||
|
Name string
|
||||||
|
|
||||||
|
// Description is used as the prefix of each generated tool's
|
||||||
|
// description text.
|
||||||
|
Description string
|
||||||
|
|
||||||
|
// EmptyStruct returns a fresh, zero-valued model instance for each
|
||||||
|
// dispatched call. Mirrors handler.WebHandler.EmptyStruct.
|
||||||
|
EmptyStruct func() handler.CObject
|
||||||
|
|
||||||
|
// Ops is the bitmask of CRUD operations this resource supports.
|
||||||
|
Ops Op
|
||||||
|
|
||||||
|
// Inputs holds the per-op wrapper type. The dispatcher allocates a
|
||||||
|
// fresh value with reflection (via reflect.TypeOf(v).Elem()), JSON-
|
||||||
|
// unmarshals the call arguments into it, and then asks the wrapper to
|
||||||
|
// copy itself onto a fresh model via the inputAdapter interface.
|
||||||
|
Inputs map[Op]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// toolRef points a tool name back at its resource + op. Built once at
|
||||||
|
// registration time so the dispatcher never has to parse tool names.
|
||||||
|
type toolRef struct {
|
||||||
|
resource *Resource
|
||||||
|
op Op
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
registryMu sync.RWMutex
|
||||||
|
resources []*Resource
|
||||||
|
toolIndex = map[string]toolRef{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrDuplicateResource is returned when Register is called twice with the
|
||||||
|
// same Name.
|
||||||
|
var ErrDuplicateResource = errors.New("mcp: resource already registered")
|
||||||
|
|
||||||
|
// Register adds a resource to the package-level registry. It validates the
|
||||||
|
// shape (non-empty name, EmptyStruct present, an Inputs entry for each op
|
||||||
|
// in the Ops bitmask) and populates the tool-name lookup table so the
|
||||||
|
// dispatcher never has to string-parse tool names like
|
||||||
|
// "task_comments_read_all".
|
||||||
|
func Register(r Resource) error {
|
||||||
|
if r.Name == "" {
|
||||||
|
return errors.New("mcp: resource Name must not be empty")
|
||||||
|
}
|
||||||
|
if r.EmptyStruct == nil {
|
||||||
|
return fmt.Errorf("mcp: resource %q has no EmptyStruct", r.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
registryMu.Lock()
|
||||||
|
defer registryMu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := findResourceLocked(r.Name); exists {
|
||||||
|
return fmt.Errorf("%w: %s", ErrDuplicateResource, r.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure every enabled op has an input wrapper, otherwise the
|
||||||
|
// dispatcher would crash later with a less useful error.
|
||||||
|
for _, op := range AllOps() {
|
||||||
|
if r.Ops&op == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, has := r.Inputs[op]; !has {
|
||||||
|
return fmt.Errorf("mcp: resource %q is missing input for op %s", r.Name, op.ToolSuffix())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stored := r
|
||||||
|
resources = append(resources, &stored)
|
||||||
|
|
||||||
|
for _, op := range AllOps() {
|
||||||
|
if stored.Ops&op == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
toolName := stored.Name + "_" + op.ToolSuffix()
|
||||||
|
toolIndex[toolName] = toolRef{resource: &stored, op: op}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupResource returns the registered resource with the given name.
|
||||||
|
// Intended for tests and internal callers; external code should resolve
|
||||||
|
// via tool name.
|
||||||
|
func lookupResource(name string) (*Resource, bool) {
|
||||||
|
registryMu.RLock()
|
||||||
|
defer registryMu.RUnlock()
|
||||||
|
return findResourceLocked(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findResourceLocked(name string) (*Resource, bool) {
|
||||||
|
for _, r := range resources {
|
||||||
|
if r.Name == name {
|
||||||
|
return r, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupTool returns the (resource, op) pair the given tool name was
|
||||||
|
// registered for.
|
||||||
|
func lookupTool(toolName string) (toolRef, bool) {
|
||||||
|
registryMu.RLock()
|
||||||
|
defer registryMu.RUnlock()
|
||||||
|
ref, ok := toolIndex[toolName]
|
||||||
|
return ref, ok
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,225 @@
|
||||||
|
// Vikunja is a to-do list application to facilitate your life.
|
||||||
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.vikunja.io/api/pkg/web/handler"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// resetRegistry clears the package-level registry so each test starts from
|
||||||
|
// a clean slate. Tests that mutate the registry should call this at the top.
|
||||||
|
func resetRegistry(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
registryMu.Lock()
|
||||||
|
defer registryMu.Unlock()
|
||||||
|
resources = nil
|
||||||
|
toolIndex = map[string]toolRef{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpPermission(t *testing.T) {
|
||||||
|
cases := map[Op]string{
|
||||||
|
OpCreate: "create",
|
||||||
|
OpReadOne: "read_one",
|
||||||
|
OpReadAll: "read_all",
|
||||||
|
OpUpdate: "update",
|
||||||
|
OpDelete: "delete",
|
||||||
|
}
|
||||||
|
for op, want := range cases {
|
||||||
|
assert.Equalf(t, want, op.Permission(), "Permission() for op %d", op)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpToolSuffix(t *testing.T) {
|
||||||
|
cases := map[Op]string{
|
||||||
|
OpCreate: "create",
|
||||||
|
OpReadOne: "read_one",
|
||||||
|
OpReadAll: "read_all",
|
||||||
|
OpUpdate: "update",
|
||||||
|
OpDelete: "delete",
|
||||||
|
}
|
||||||
|
for op, want := range cases {
|
||||||
|
assert.Equalf(t, want, op.ToolSuffix(), "ToolSuffix() for op %d", op)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpUnknownPermission(t *testing.T) {
|
||||||
|
// Combined bitmasks and zero values have no defined permission string.
|
||||||
|
assert.Empty(t, Op(0).Permission())
|
||||||
|
assert.Empty(t, (OpCreate | OpReadOne).Permission())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterAppends(t *testing.T) {
|
||||||
|
resetRegistry(t)
|
||||||
|
|
||||||
|
r := Resource{
|
||||||
|
Name: "stubs",
|
||||||
|
Description: "test resource",
|
||||||
|
EmptyStruct: func() handler.CObject { return &stubCObject{} },
|
||||||
|
Ops: OpCreate | OpReadOne,
|
||||||
|
Inputs: map[Op]any{
|
||||||
|
OpCreate: &struct{}{},
|
||||||
|
OpReadOne: &struct{}{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.NoError(t, Register(r))
|
||||||
|
|
||||||
|
got, ok := lookupResource("stubs")
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "stubs", got.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterDuplicateName(t *testing.T) {
|
||||||
|
resetRegistry(t)
|
||||||
|
|
||||||
|
r := Resource{
|
||||||
|
Name: "stubs",
|
||||||
|
EmptyStruct: func() handler.CObject { return &stubCObject{} },
|
||||||
|
Ops: OpReadOne,
|
||||||
|
Inputs: map[Op]any{OpReadOne: &struct{}{}},
|
||||||
|
}
|
||||||
|
require.NoError(t, Register(r))
|
||||||
|
err := Register(r)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "already registered")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterMissingInputForOp(t *testing.T) {
|
||||||
|
resetRegistry(t)
|
||||||
|
|
||||||
|
r := Resource{
|
||||||
|
Name: "stubs",
|
||||||
|
EmptyStruct: func() handler.CObject { return &stubCObject{} },
|
||||||
|
Ops: OpCreate | OpReadOne,
|
||||||
|
// Missing input wrapper for OpReadOne.
|
||||||
|
Inputs: map[Op]any{OpCreate: &struct{}{}},
|
||||||
|
}
|
||||||
|
err := Register(r)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "input")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterEmptyName(t *testing.T) {
|
||||||
|
resetRegistry(t)
|
||||||
|
|
||||||
|
err := Register(Resource{
|
||||||
|
EmptyStruct: func() handler.CObject { return &stubCObject{} },
|
||||||
|
Ops: OpReadOne,
|
||||||
|
Inputs: map[Op]any{OpReadOne: &struct{}{}},
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterRequiresEmptyStruct(t *testing.T) {
|
||||||
|
resetRegistry(t)
|
||||||
|
|
||||||
|
err := Register(Resource{
|
||||||
|
Name: "stubs",
|
||||||
|
Ops: OpReadOne,
|
||||||
|
Inputs: map[Op]any{OpReadOne: &struct{}{}},
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolNameResolver(t *testing.T) {
|
||||||
|
resetRegistry(t)
|
||||||
|
|
||||||
|
require.NoError(t, Register(Resource{
|
||||||
|
Name: "projects",
|
||||||
|
EmptyStruct: func() handler.CObject { return &stubCObject{} },
|
||||||
|
Ops: OpCreate | OpReadOne | OpReadAll | OpUpdate | OpDelete,
|
||||||
|
Inputs: map[Op]any{
|
||||||
|
OpCreate: &struct{}{},
|
||||||
|
OpReadOne: &struct{}{},
|
||||||
|
OpReadAll: &struct{}{},
|
||||||
|
OpUpdate: &struct{}{},
|
||||||
|
OpDelete: &struct{}{},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
require.NoError(t, Register(Resource{
|
||||||
|
Name: "task_comments",
|
||||||
|
EmptyStruct: func() handler.CObject { return &stubCObject{} },
|
||||||
|
Ops: OpReadAll,
|
||||||
|
Inputs: map[Op]any{OpReadAll: &struct{}{}},
|
||||||
|
}))
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
toolName string
|
||||||
|
resource string
|
||||||
|
op Op
|
||||||
|
}{
|
||||||
|
{"projects_create", "projects", OpCreate},
|
||||||
|
{"projects_read_one", "projects", OpReadOne},
|
||||||
|
{"projects_read_all", "projects", OpReadAll},
|
||||||
|
{"projects_update", "projects", OpUpdate},
|
||||||
|
{"projects_delete", "projects", OpDelete},
|
||||||
|
{"task_comments_read_all", "task_comments", OpReadAll},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
ref, ok := lookupTool(tc.toolName)
|
||||||
|
require.Truef(t, ok, "tool %s should be resolvable", tc.toolName)
|
||||||
|
assert.Equal(t, tc.resource, ref.resource.Name, "tool %s", tc.toolName)
|
||||||
|
assert.Equal(t, tc.op, ref.op, "tool %s", tc.toolName)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok := lookupTool("nonexistent_tool")
|
||||||
|
assert.False(t, ok)
|
||||||
|
|
||||||
|
// `task_comments_read_all` must resolve to (task_comments, read_all),
|
||||||
|
// not to (task, comments_read_all) or any naive underscore split.
|
||||||
|
ref, ok := lookupTool("task_comments_read_all")
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "task_comments", ref.resource.Name)
|
||||||
|
assert.Equal(t, OpReadAll, ref.op)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterOnlyExposesEnabledOps(t *testing.T) {
|
||||||
|
resetRegistry(t)
|
||||||
|
|
||||||
|
require.NoError(t, Register(Resource{
|
||||||
|
Name: "stubs",
|
||||||
|
EmptyStruct: func() handler.CObject { return &stubCObject{} },
|
||||||
|
Ops: OpReadOne | OpReadAll,
|
||||||
|
Inputs: map[Op]any{
|
||||||
|
OpReadOne: &struct{}{},
|
||||||
|
OpReadAll: &struct{}{},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
_, ok := lookupTool("stubs_read_one")
|
||||||
|
assert.True(t, ok)
|
||||||
|
_, ok = lookupTool("stubs_read_all")
|
||||||
|
assert.True(t, ok)
|
||||||
|
|
||||||
|
// Ops that weren't enabled in the bitmask must not appear.
|
||||||
|
_, ok = lookupTool("stubs_create")
|
||||||
|
assert.False(t, ok)
|
||||||
|
_, ok = lookupTool("stubs_delete")
|
||||||
|
assert.False(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllOps(t *testing.T) {
|
||||||
|
// AllOps must enumerate exactly the five supported ops so the registry
|
||||||
|
// and the dispatcher walk the same list.
|
||||||
|
want := []Op{OpCreate, OpReadOne, OpReadAll, OpUpdate, OpDelete}
|
||||||
|
assert.Equal(t, want, AllOps())
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue