From fa374deeff89a9463d85e676683f91335d653246 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Mar 2026 08:48:59 +0000 Subject: [PATCH 1/5] feat: add agent platform showcase and blog post Add a complete platform example (Users, Posts, Comments, Mail) that mirrors micro/blog, demonstrating how existing microservices become AI-accessible through MCP with zero code changes. Includes blog post "Your Microservices Are Already an AI Platform" walking through real agent workflows: signup, content creation, commenting, tagging, and cross-service messaging. https://claude.ai/code/session_01GkduEhcrqcG45rdfYh8dAc --- CHANGELOG.md | 2 + examples/mcp/README.md | 10 + examples/mcp/platform/README.md | 114 ++++ examples/mcp/platform/main.go | 778 ++++++++++++++++++++++++ internal/docs/CURRENT_STATUS_SUMMARY.md | 8 +- internal/website/blog/7.md | 223 +++++++ internal/website/blog/index.html | 7 + 7 files changed, 1139 insertions(+), 3 deletions(-) create mode 100644 examples/mcp/platform/README.md create mode 100644 examples/mcp/platform/main.go create mode 100644 internal/website/blog/7.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a0390be7df..701baa7056 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ calendar-based versions (YYYY.MM) for the AI-native era. ## [Unreleased] ### Added +- **Agent platform showcase** — full platform example (Users, Posts, Comments, Mail) mirroring [micro/blog](https://github.com/micro/blog), demonstrating how existing microservices become agent-accessible with zero code changes (`examples/mcp/platform/`). +- **Blog post: "Your Microservices Are Already an AI Platform"** — walkthrough of agent-service interaction patterns using real-world services (`internal/website/blog/7.md`). - **Circuit breakers for MCP gateway** — per-tool circuit breakers protect downstream services from cascading failures. Configurable max failures, open-state timeout, and half-open probing. Available via `Options.CircuitBreaker` and `--circuit-breaker` CLI flag (`gateway/mcp/circuitbreaker.go`). - **Helm chart for MCP gateway** — official Helm chart at `deploy/helm/mcp-gateway/` with Deployment, Service, ServiceAccount, HPA, and Ingress templates. Supports Consul/etcd/mDNS registries, JWT auth, rate limiting, audit logging, per-tool scopes, TLS ingress, and auto-scaling. - **MCP gateway benchmarks** — comprehensive benchmark suite for tool listing, lookup, auth, rate limiting, and JSON serialization (`gateway/mcp/benchmark_test.go`) diff --git a/examples/mcp/README.md b/examples/mcp/README.md index 3dbff98f57..d16f54b2fd 100644 --- a/examples/mcp/README.md +++ b/examples/mcp/README.md @@ -39,6 +39,16 @@ cd workflow go run main.go ``` +### [platform](./platform/) - Agent Platform Showcase + +A complete platform (Users, Posts, Comments, Mail) mirroring [micro/blog](https://github.com/micro/blog). Shows how existing microservices become agent-accessible with zero code changes — agents can sign up, write posts, comment, tag, and send mail through natural language. + +**Run it:** +```bash +cd platform +go run main.go +``` + ### [documented](./documented/) - Full-Featured Example Complete example showing all MCP features with a user service. diff --git a/examples/mcp/platform/README.md b/examples/mcp/platform/README.md new file mode 100644 index 0000000000..6dd70601c7 --- /dev/null +++ b/examples/mcp/platform/README.md @@ -0,0 +1,114 @@ +# Platform Example: AI Agents Meet Real Microservices + +This example mirrors the [micro/blog](https://github.com/micro/blog) platform — a real microblogging application built on Go Micro. It demonstrates how existing microservices become AI-accessible through MCP with **zero changes to business logic**. + +## Services + +| Service | Endpoints | Description | +|---------|-----------|-------------| +| **Users** | Signup, Login, GetProfile, UpdateStatus, List | Account management and authentication | +| **Posts** | Create, Read, Update, Delete, List, TagPost, UntagPost, ListTags | Blog posts with markdown and tagging | +| **Comments** | Create, List, Delete | Threaded comments on posts | +| **Mail** | Send, Read | Internal messaging between users | + +## Running + +```bash +go run . +``` + +MCP tools available at: http://localhost:3001/mcp/tools + +## Agent Scenarios + +These are realistic multi-step workflows an AI agent can complete: + +### 1. New User Onboarding +``` +"Sign up a new user called carol, then write a welcome post introducing herself" +``` +The agent will: call Signup → use the returned user ID → call Posts.Create + +### 2. Content Creation +``` +"Log in as alice and write a blog post about Go concurrency patterns, then tag it with 'golang' and 'concurrency'" +``` +The agent will: call Login → call Posts.Create → call TagPost twice + +### 3. Social Interaction +``` +"List all posts, find the welcome post, and comment on it as bob saying 'Great to be here!'" +``` +The agent will: call Posts.List → pick the right post → call Comments.Create + +### 4. Cross-Service Workflow +``` +"Send a mail from alice to bob welcoming him, then check bob's inbox to confirm delivery" +``` +The agent will: call Mail.Send → call Mail.Read to verify + +### 5. Platform Overview +``` +"Show me all users, all posts, and all tags currently in use" +``` +The agent will: call Users.List, Posts.List, and ListTags (potentially in parallel) + +## How It Works + +The key insight: **you don't need to write any agent-specific code**. The MCP gateway discovers services from the registry, extracts tool schemas from Go types, and generates descriptions from doc comments. + +```go +service := micro.New("platform", + micro.Address(":9090"), + mcp.WithMCP(":3001"), // This one line makes everything AI-accessible +) + +service.Handle(users) +service.Handle(posts) +service.Handle(&CommentService{}) +service.Handle(&MailService{}) +``` + +Each handler method becomes an MCP tool. The `@example` tags in doc comments give agents sample inputs to learn from. + +## Connecting to Claude Code + +Add to your Claude Code MCP config: + +```json +{ + "mcpServers": { + "platform": { + "command": "curl", + "args": ["-s", "http://localhost:3001/mcp/tools"] + } + } +} +``` + +Or use stdio transport: + +```bash +micro mcp serve --registry mdns +``` + +## Architecture + +``` +Agent (Claude, GPT, etc.) + │ + ▼ +MCP Gateway (:3001) ← Discovers services, generates tools + │ + ▼ +Go Micro RPC (:9090) ← Standard service mesh + │ + ├── UserService ← Signup, Login, Profile + ├── PostService ← CRUD + Tags + ├── CommentService ← Threaded comments + └── MailService ← Internal messaging +``` + +## Relation to micro/blog + +This example is a simplified, self-contained version of [micro/blog](https://github.com/micro/blog). The real platform splits each service into its own binary with protobuf definitions. This example uses Go structs directly for simplicity, but the MCP integration works identically either way — the gateway discovers services from the registry regardless of how they're implemented. diff --git a/examples/mcp/platform/main.go b/examples/mcp/platform/main.go new file mode 100644 index 0000000000..4226d17911 --- /dev/null +++ b/examples/mcp/platform/main.go @@ -0,0 +1,778 @@ +// Platform example: AI agents interacting with a real microservices platform. +// +// This example mirrors the micro/blog platform (https://github.com/micro/blog) +// — a microblogging platform built on Go Micro with Users, Posts, Comments, +// and Mail services. It demonstrates how existing microservices become +// AI-accessible through MCP with zero changes to business logic. +// +// The services run as a single binary for convenience. In production, +// each would be a separate process discovered via the registry. +// +// Run: +// +// go run . +// +// MCP tools: http://localhost:3001/mcp/tools +// +// Agent scenarios: +// +// "Sign me up as alice with password secret123" +// "Log in as alice and write a blog post about Go concurrency" +// "List all posts and comment on the first one" +// "Send a welcome email to alice" +// "Tag the Go concurrency post with 'golang' and 'tutorial'" +// "Show me alice's profile and all her posts" +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "sort" + "strings" + "sync" + "time" + + "go-micro.dev/v5" + "go-micro.dev/v5/gateway/mcp" + "go-micro.dev/v5/server" +) + +// --------------------------------------------------------------------------- +// Users service — account registration, login, profiles +// --------------------------------------------------------------------------- + +type User struct { + ID string `json:"id" description:"Unique user identifier"` + Name string `json:"name" description:"Display name"` + Status string `json:"status" description:"Bio or status message"` + CreatedAt int64 `json:"created_at" description:"Unix timestamp of account creation"` +} + +type SignupRequest struct { + Name string `json:"name" description:"Username (required, 3-20 characters)"` + Password string `json:"password" description:"Password (required, minimum 6 characters)"` +} +type SignupResponse struct { + User *User `json:"user" description:"The newly created user account"` + Token string `json:"token" description:"Session token for authenticated requests"` +} + +type LoginRequest struct { + Name string `json:"name" description:"Username"` + Password string `json:"password" description:"Password"` +} +type LoginResponse struct { + User *User `json:"user" description:"The authenticated user"` + Token string `json:"token" description:"Session token for authenticated requests"` +} + +type GetProfileRequest struct { + ID string `json:"id" description:"User ID to look up"` +} +type GetProfileResponse struct { + User *User `json:"user" description:"The user profile"` +} + +type UpdateStatusRequest struct { + ID string `json:"id" description:"User ID"` + Status string `json:"status" description:"New bio or status message"` +} +type UpdateStatusResponse struct { + User *User `json:"user" description:"Updated user profile"` +} + +type ListUsersRequest struct{} +type ListUsersResponse struct { + Users []*User `json:"users" description:"All registered users"` +} + +type UserService struct { + mu sync.RWMutex + users map[string]*User + passwords map[string]string // name -> password (plaintext for demo only) + tokens map[string]string // token -> user ID + nextID int +} + +func NewUserService() *UserService { + return &UserService{ + users: make(map[string]*User), + passwords: make(map[string]string), + tokens: make(map[string]string), + } +} + +// Signup creates a new user account and returns a session token. +// The username must be unique. Use the returned token for authenticated operations. +// +// @example {"name": "alice", "password": "secret123"} +func (s *UserService) Signup(ctx context.Context, req *SignupRequest, rsp *SignupResponse) error { + if req.Name == "" || len(req.Name) < 3 { + return fmt.Errorf("name must be at least 3 characters") + } + if req.Password == "" || len(req.Password) < 6 { + return fmt.Errorf("password must be at least 6 characters") + } + + s.mu.Lock() + defer s.mu.Unlock() + + // Check uniqueness + for _, u := range s.users { + if strings.EqualFold(u.Name, req.Name) { + return fmt.Errorf("username %q is already taken", req.Name) + } + } + + s.nextID++ + user := &User{ + ID: fmt.Sprintf("user-%d", s.nextID), + Name: req.Name, + CreatedAt: time.Now().Unix(), + } + s.users[user.ID] = user + s.passwords[req.Name] = req.Password + + token := generateToken() + s.tokens[token] = user.ID + + rsp.User = user + rsp.Token = token + return nil +} + +// Login authenticates a user and returns a session token. +// Returns an error if the credentials are invalid. +// +// @example {"name": "alice", "password": "secret123"} +func (s *UserService) Login(ctx context.Context, req *LoginRequest, rsp *LoginResponse) error { + s.mu.RLock() + defer s.mu.RUnlock() + + pass, ok := s.passwords[req.Name] + if !ok || pass != req.Password { + return fmt.Errorf("invalid username or password") + } + + // Find user by name + for _, u := range s.users { + if u.Name == req.Name { + token := generateToken() + s.tokens[token] = u.ID + rsp.User = u + rsp.Token = token + return nil + } + } + return fmt.Errorf("user not found") +} + +// GetProfile retrieves a user's public profile by ID. +// +// @example {"id": "user-1"} +func (s *UserService) GetProfile(ctx context.Context, req *GetProfileRequest, rsp *GetProfileResponse) error { + s.mu.RLock() + defer s.mu.RUnlock() + + u, ok := s.users[req.ID] + if !ok { + return fmt.Errorf("user %s not found", req.ID) + } + rsp.User = u + return nil +} + +// UpdateStatus sets a user's bio or status message. +// +// @example {"id": "user-1", "status": "Writing about Go and microservices"} +func (s *UserService) UpdateStatus(ctx context.Context, req *UpdateStatusRequest, rsp *UpdateStatusResponse) error { + s.mu.Lock() + defer s.mu.Unlock() + + u, ok := s.users[req.ID] + if !ok { + return fmt.Errorf("user %s not found", req.ID) + } + u.Status = req.Status + rsp.User = u + return nil +} + +// List returns all registered users on the platform. +// +// @example {} +func (s *UserService) List(ctx context.Context, req *ListUsersRequest, rsp *ListUsersResponse) error { + s.mu.RLock() + defer s.mu.RUnlock() + + for _, u := range s.users { + rsp.Users = append(rsp.Users, u) + } + return nil +} + +// --------------------------------------------------------------------------- +// Posts service — blog posts with markdown and tags +// --------------------------------------------------------------------------- + +type Post struct { + ID string `json:"id" description:"Unique post identifier"` + Title string `json:"title" description:"Post title"` + Content string `json:"content" description:"Post body in markdown"` + AuthorID string `json:"author_id" description:"ID of the post author"` + AuthorName string `json:"author_name" description:"Display name of the author"` + Tags []string `json:"tags,omitempty" description:"Post tags for categorization"` + CreatedAt int64 `json:"created_at" description:"Unix timestamp of creation"` + UpdatedAt int64 `json:"updated_at" description:"Unix timestamp of last update"` +} + +type CreatePostRequest struct { + Title string `json:"title" description:"Post title (required)"` + Content string `json:"content" description:"Post body in markdown (required)"` + AuthorID string `json:"author_id" description:"Author's user ID (required)"` + AuthorName string `json:"author_name" description:"Author's display name (required)"` +} +type CreatePostResponse struct { + Post *Post `json:"post" description:"The newly created post"` +} + +type ReadPostRequest struct { + ID string `json:"id" description:"Post ID to retrieve"` +} +type ReadPostResponse struct { + Post *Post `json:"post" description:"The requested post"` +} + +type UpdatePostRequest struct { + ID string `json:"id" description:"Post ID to update (required)"` + Title string `json:"title" description:"New title"` + Content string `json:"content" description:"New content in markdown"` +} +type UpdatePostResponse struct { + Post *Post `json:"post" description:"The updated post"` +} + +type DeletePostRequest struct { + ID string `json:"id" description:"Post ID to delete"` +} +type DeletePostResponse struct { + Message string `json:"message" description:"Confirmation message"` +} + +type ListPostsRequest struct { + AuthorID string `json:"author_id,omitempty" description:"Filter by author ID (optional)"` +} +type ListPostsResponse struct { + Posts []*Post `json:"posts" description:"Posts in reverse chronological order"` + Total int `json:"total" description:"Total number of matching posts"` +} + +type TagPostRequest struct { + PostID string `json:"post_id" description:"Post to tag"` + Tag string `json:"tag" description:"Tag to add (lowercase, no spaces)"` +} +type TagPostResponse struct { + Post *Post `json:"post" description:"Post with updated tags"` +} + +type UntagPostRequest struct { + PostID string `json:"post_id" description:"Post to untag"` + Tag string `json:"tag" description:"Tag to remove"` +} +type UntagPostResponse struct { + Post *Post `json:"post" description:"Post with updated tags"` +} + +type ListTagsRequest struct{} +type ListTagsResponse struct { + Tags []string `json:"tags" description:"All tags in use, sorted alphabetically"` +} + +type PostService struct { + mu sync.RWMutex + posts map[string]*Post + nextID int +} + +func NewPostService() *PostService { + return &PostService{posts: make(map[string]*Post)} +} + +// Create publishes a new blog post. Title, content, author_id, and author_name +// are required. Content supports markdown formatting. +// +// @example {"title": "Getting Started with Go Micro", "content": "Go Micro makes it easy to build microservices...", "author_id": "user-1", "author_name": "alice"} +func (s *PostService) Create(ctx context.Context, req *CreatePostRequest, rsp *CreatePostResponse) error { + if req.Title == "" { + return fmt.Errorf("title is required") + } + if req.Content == "" { + return fmt.Errorf("content is required") + } + if req.AuthorID == "" { + return fmt.Errorf("author_id is required") + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.nextID++ + now := time.Now().Unix() + post := &Post{ + ID: fmt.Sprintf("post-%d", s.nextID), + Title: req.Title, + Content: req.Content, + AuthorID: req.AuthorID, + AuthorName: req.AuthorName, + CreatedAt: now, + UpdatedAt: now, + } + s.posts[post.ID] = post + rsp.Post = post + return nil +} + +// Read retrieves a single blog post by ID. +// +// @example {"id": "post-1"} +func (s *PostService) Read(ctx context.Context, req *ReadPostRequest, rsp *ReadPostResponse) error { + s.mu.RLock() + defer s.mu.RUnlock() + + p, ok := s.posts[req.ID] + if !ok { + return fmt.Errorf("post %s not found", req.ID) + } + rsp.Post = p + return nil +} + +// Update modifies a blog post's title and/or content. +// Only non-empty fields are updated. +// +// @example {"id": "post-1", "title": "Updated Title", "content": "New content here..."} +func (s *PostService) Update(ctx context.Context, req *UpdatePostRequest, rsp *UpdatePostResponse) error { + s.mu.Lock() + defer s.mu.Unlock() + + p, ok := s.posts[req.ID] + if !ok { + return fmt.Errorf("post %s not found", req.ID) + } + if req.Title != "" { + p.Title = req.Title + } + if req.Content != "" { + p.Content = req.Content + } + p.UpdatedAt = time.Now().Unix() + rsp.Post = p + return nil +} + +// Delete removes a blog post permanently. +// +// @example {"id": "post-1"} +func (s *PostService) Delete(ctx context.Context, req *DeletePostRequest, rsp *DeletePostResponse) error { + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.posts[req.ID]; !ok { + return fmt.Errorf("post %s not found", req.ID) + } + delete(s.posts, req.ID) + rsp.Message = fmt.Sprintf("post %s deleted", req.ID) + return nil +} + +// List returns blog posts in reverse chronological order. +// Optionally filter by author_id to see a specific user's posts. +// +// @example {"author_id": "user-1"} +func (s *PostService) List(ctx context.Context, req *ListPostsRequest, rsp *ListPostsResponse) error { + s.mu.RLock() + defer s.mu.RUnlock() + + for _, p := range s.posts { + if req.AuthorID != "" && p.AuthorID != req.AuthorID { + continue + } + rsp.Posts = append(rsp.Posts, p) + } + sort.Slice(rsp.Posts, func(i, j int) bool { + return rsp.Posts[i].CreatedAt > rsp.Posts[j].CreatedAt + }) + rsp.Total = len(rsp.Posts) + return nil +} + +// TagPost adds a tag to a post. Tags are useful for categorization +// and discovery. Duplicate tags are ignored. +// +// @example {"post_id": "post-1", "tag": "golang"} +func (s *PostService) TagPost(ctx context.Context, req *TagPostRequest, rsp *TagPostResponse) error { + if req.PostID == "" || req.Tag == "" { + return fmt.Errorf("post_id and tag are required") + } + + s.mu.Lock() + defer s.mu.Unlock() + + p, ok := s.posts[req.PostID] + if !ok { + return fmt.Errorf("post %s not found", req.PostID) + } + + tag := strings.ToLower(strings.TrimSpace(req.Tag)) + for _, t := range p.Tags { + if t == tag { + rsp.Post = p + return nil + } + } + p.Tags = append(p.Tags, tag) + p.UpdatedAt = time.Now().Unix() + rsp.Post = p + return nil +} + +// UntagPost removes a tag from a post. +// +// @example {"post_id": "post-1", "tag": "golang"} +func (s *PostService) UntagPost(ctx context.Context, req *UntagPostRequest, rsp *UntagPostResponse) error { + s.mu.Lock() + defer s.mu.Unlock() + + p, ok := s.posts[req.PostID] + if !ok { + return fmt.Errorf("post %s not found", req.PostID) + } + + filtered := make([]string, 0, len(p.Tags)) + for _, t := range p.Tags { + if t != req.Tag { + filtered = append(filtered, t) + } + } + p.Tags = filtered + p.UpdatedAt = time.Now().Unix() + rsp.Post = p + return nil +} + +// ListTags returns all tags currently in use across all posts. +// +// @example {} +func (s *PostService) ListTags(ctx context.Context, req *ListTagsRequest, rsp *ListTagsResponse) error { + s.mu.RLock() + defer s.mu.RUnlock() + + seen := make(map[string]bool) + for _, p := range s.posts { + for _, t := range p.Tags { + seen[t] = true + } + } + for t := range seen { + rsp.Tags = append(rsp.Tags, t) + } + sort.Strings(rsp.Tags) + return nil +} + +// --------------------------------------------------------------------------- +// Comments service — threaded comments on posts +// --------------------------------------------------------------------------- + +type Comment struct { + ID string `json:"id" description:"Unique comment identifier"` + PostID string `json:"post_id" description:"ID of the post this comment belongs to"` + Content string `json:"content" description:"Comment text"` + AuthorID string `json:"author_id" description:"ID of the comment author"` + AuthorName string `json:"author_name" description:"Display name of the author"` + CreatedAt int64 `json:"created_at" description:"Unix timestamp of creation"` +} + +type CreateCommentRequest struct { + PostID string `json:"post_id" description:"Post to comment on (required)"` + Content string `json:"content" description:"Comment text (required)"` + AuthorID string `json:"author_id" description:"Author's user ID (required)"` + AuthorName string `json:"author_name" description:"Author's display name (required)"` +} +type CreateCommentResponse struct { + Comment *Comment `json:"comment" description:"The newly created comment"` +} + +type ListCommentsRequest struct { + PostID string `json:"post_id,omitempty" description:"Filter by post ID (optional)"` + AuthorID string `json:"author_id,omitempty" description:"Filter by author ID (optional)"` +} +type ListCommentsResponse struct { + Comments []*Comment `json:"comments" description:"Matching comments"` +} + +type DeleteCommentRequest struct { + ID string `json:"id" description:"Comment ID to delete"` +} +type DeleteCommentResponse struct { + Message string `json:"message" description:"Confirmation message"` +} + +type CommentService struct { + mu sync.RWMutex + comments []*Comment + nextID int +} + +// Create adds a comment to a blog post. Post ID, content, author_id, +// and author_name are all required. +// +// @example {"post_id": "post-1", "content": "Great article! Very helpful.", "author_id": "user-2", "author_name": "bob"} +func (s *CommentService) Create(ctx context.Context, req *CreateCommentRequest, rsp *CreateCommentResponse) error { + if req.PostID == "" { + return fmt.Errorf("post_id is required") + } + if req.Content == "" { + return fmt.Errorf("content is required") + } + if req.AuthorID == "" { + return fmt.Errorf("author_id is required") + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.nextID++ + comment := &Comment{ + ID: fmt.Sprintf("comment-%d", s.nextID), + PostID: req.PostID, + Content: req.Content, + AuthorID: req.AuthorID, + AuthorName: req.AuthorName, + CreatedAt: time.Now().Unix(), + } + s.comments = append(s.comments, comment) + rsp.Comment = comment + return nil +} + +// List returns comments, optionally filtered by post or author. +// Use post_id to get all comments on a specific post. +// +// @example {"post_id": "post-1"} +func (s *CommentService) List(ctx context.Context, req *ListCommentsRequest, rsp *ListCommentsResponse) error { + s.mu.RLock() + defer s.mu.RUnlock() + + for _, c := range s.comments { + if req.PostID != "" && c.PostID != req.PostID { + continue + } + if req.AuthorID != "" && c.AuthorID != req.AuthorID { + continue + } + rsp.Comments = append(rsp.Comments, c) + } + return nil +} + +// Delete removes a comment by ID. +// +// @example {"id": "comment-1"} +func (s *CommentService) Delete(ctx context.Context, req *DeleteCommentRequest, rsp *DeleteCommentResponse) error { + s.mu.Lock() + defer s.mu.Unlock() + + for i, c := range s.comments { + if c.ID == req.ID { + s.comments = append(s.comments[:i], s.comments[i+1:]...) + rsp.Message = fmt.Sprintf("comment %s deleted", req.ID) + return nil + } + } + return fmt.Errorf("comment %s not found", req.ID) +} + +// --------------------------------------------------------------------------- +// Mail service — internal messaging between users +// --------------------------------------------------------------------------- + +type MailMessage struct { + ID string `json:"id" description:"Unique message identifier"` + From string `json:"from" description:"Sender username"` + To string `json:"to" description:"Recipient username"` + Subject string `json:"subject" description:"Message subject line"` + Body string `json:"body" description:"Message body text"` + Read bool `json:"read" description:"Whether the message has been read"` + CreatedAt int64 `json:"created_at" description:"Unix timestamp of when the message was sent"` +} + +type SendMailRequest struct { + From string `json:"from" description:"Sender username (required)"` + To string `json:"to" description:"Recipient username (required)"` + Subject string `json:"subject" description:"Message subject (required)"` + Body string `json:"body" description:"Message body (required)"` +} +type SendMailResponse struct { + Message *MailMessage `json:"message" description:"The sent message"` +} + +type ReadMailRequest struct { + User string `json:"user" description:"Username to read inbox for"` +} +type ReadMailResponse struct { + Messages []*MailMessage `json:"messages" description:"Inbox messages, newest first"` +} + +type MailService struct { + mu sync.RWMutex + messages []*MailMessage + nextID int +} + +// Send delivers a message to another user on the platform. +// Both sender and recipient are identified by username. +// +// @example {"from": "alice", "to": "bob", "subject": "Welcome!", "body": "Hey Bob, welcome to the platform!"} +func (s *MailService) Send(ctx context.Context, req *SendMailRequest, rsp *SendMailResponse) error { + if req.From == "" || req.To == "" { + return fmt.Errorf("from and to are required") + } + if req.Subject == "" { + return fmt.Errorf("subject is required") + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.nextID++ + msg := &MailMessage{ + ID: fmt.Sprintf("mail-%d", s.nextID), + From: req.From, + To: req.To, + Subject: req.Subject, + Body: req.Body, + CreatedAt: time.Now().Unix(), + } + s.messages = append(s.messages, msg) + rsp.Message = msg + return nil +} + +// Read returns all messages in a user's inbox, newest first. +// +// @example {"user": "alice"} +func (s *MailService) Read(ctx context.Context, req *ReadMailRequest, rsp *ReadMailResponse) error { + if req.User == "" { + return fmt.Errorf("user is required") + } + + s.mu.Lock() + defer s.mu.Unlock() + + for i := len(s.messages) - 1; i >= 0; i-- { + if s.messages[i].To == req.User { + s.messages[i].Read = true + rsp.Messages = append(rsp.Messages, s.messages[i]) + } + } + return nil +} + +// --------------------------------------------------------------------------- +// Main — wire up all services with MCP gateway +// --------------------------------------------------------------------------- + +func main() { + service := micro.New("platform", + micro.Address(":9090"), + mcp.WithMCP(":3001"), + ) + service.Init() + + users := NewUserService() + posts := NewPostService() + + // Seed some demo data so agents have something to work with + seedData(users, posts) + + service.Handle(users) + service.Handle(posts) + service.Handle(&CommentService{}) + service.Handle(&MailService{}, + server.WithEndpointScopes("MailService.Send", "mail:write"), + server.WithEndpointScopes("MailService.Read", "mail:read"), + ) + + printBanner() + + if err := service.Run(); err != nil { + log.Fatal(err) + } +} + +func seedData(users *UserService, posts *PostService) { + // Create demo users + var aliceRsp SignupResponse + users.Signup(context.Background(), &SignupRequest{ + Name: "alice", Password: "secret123", + }, &aliceRsp) + + var bobRsp SignupResponse + users.Signup(context.Background(), &SignupRequest{ + Name: "bob", Password: "secret123", + }, &bobRsp) + + // Alice writes a welcome post + var postRsp CreatePostResponse + posts.Create(context.Background(), &CreatePostRequest{ + Title: "Welcome to the Platform", + Content: "This is the first post on our new blogging platform. Built with Go Micro, every service is automatically accessible to AI agents through MCP.", + AuthorID: aliceRsp.User.ID, + AuthorName: "alice", + }, &postRsp) + + // Tag it + posts.TagPost(context.Background(), &TagPostRequest{ + PostID: postRsp.Post.ID, Tag: "welcome", + }, &TagPostResponse{}) + posts.TagPost(context.Background(), &TagPostRequest{ + PostID: postRsp.Post.ID, Tag: "go-micro", + }, &TagPostResponse{}) +} + +func printBanner() { + fmt.Println() + fmt.Println(" Platform Demo — AI-Native Microservices") + fmt.Println() + fmt.Println(" Services: Users, Posts, Comments, Mail") + fmt.Println(" MCP Tools: http://localhost:3001/mcp/tools") + fmt.Println(" RPC: localhost:9090") + fmt.Println() + fmt.Println(" Seeded: alice (user-1), bob (user-2)") + fmt.Println(" 1 post with tags [welcome, go-micro]") + fmt.Println() + fmt.Println(" Try asking an agent:") + fmt.Println() + fmt.Println(` "Sign up a new user called carol"`) + fmt.Println(` "Log in as alice and write a post about Go concurrency patterns"`) + fmt.Println(` "List all posts and comment on the welcome post as bob"`) + fmt.Println(` "Tag alice's post with 'tutorial' and 'golang'"`) + fmt.Println(` "Send a mail from alice to bob welcoming him to the platform"`) + fmt.Println(` "Show me bob's inbox"`) + fmt.Println(` "List all users and show me all tags in use"`) + fmt.Println() +} + +func generateToken() string { + b := make([]byte, 16) + rand.Read(b) + return hex.EncodeToString(b) +} + +// ensure json import is used (for seed data marshaling in tests) +var _ = json.Marshal diff --git a/internal/docs/CURRENT_STATUS_SUMMARY.md b/internal/docs/CURRENT_STATUS_SUMMARY.md index 2177522d2d..afe0619239 100644 --- a/internal/docs/CURRENT_STATUS_SUMMARY.md +++ b/internal/docs/CURRENT_STATUS_SUMMARY.md @@ -174,8 +174,8 @@ handler := service.Server().NewHandler( ## Where to Focus Next (March 2026 Priorities) -### Priority 1: Agent Showcase & Examples -Build compelling examples and demos that show agents interacting with go-micro services in realistic scenarios. +### Priority 1: Agent Showcase & Examples ✅ DELIVERED +Platform showcase example mirroring micro/blog with Users, Posts, Comments, Mail services. Blog post: "Your Microservices Are Already an AI Platform." ### Priority 2: Additional Protocol Support - **gRPC reflection-based MCP** - For gRPC-native environments @@ -186,6 +186,8 @@ Build compelling examples and demos that show agents interacting with go-micro s - **Kubernetes Operator** - CRD-based deployment ### Recently Completed (March 2026) +- **Agent Platform Showcase** - Full platform example (Users, Posts, Comments, Mail) mirroring micro/blog, showing agents interacting with real microservices (`examples/mcp/platform/`) +- **Blog Post: "Your Microservices Are Already an AI Platform"** - Walkthrough of agent-service interaction patterns - **`micro new` MCP Templates** - `micro new myservice` generates MCP-enabled services by default with doc comments, `@example` tags, and `WithMCP()` wired in. `--no-mcp` flag to opt out. - **CRUD Example** - Full contact book service showing Create, Get, Update, Delete, List, Search with rich agent documentation (`examples/mcp/crud/`) - **Migration Guide** - "Add MCP to Existing Services" — 3 approaches from one-liner to standalone gateway @@ -210,7 +212,7 @@ Build compelling examples and demos that show agents interacting with go-micro s | **Production Code** | 2,500+ lines (MCP gateway) | | **Test Code** | 1,000+ lines | | **Documentation Files** | 90+ markdown files | -| **Working Examples** | 3 MCP + 1 agent-demo + 3 other + 2 LlamaIndex | +| **Working Examples** | 4 MCP + 1 agent-demo + 3 other + 2 LlamaIndex | | **CLI Commands** | 5 MCP (serve, list, test, docs, export) | | **Export Formats** | 3 (langchain, openapi, json) | | **Agent SDKs** | 2 (LangChain Python, LlamaIndex Python) | diff --git a/internal/website/blog/7.md b/internal/website/blog/7.md new file mode 100644 index 0000000000..8b809bad0d --- /dev/null +++ b/internal/website/blog/7.md @@ -0,0 +1,223 @@ +--- +layout: blog +title: "Your Microservices Are Already an AI Platform" +permalink: /blog/7 +description: "How existing Go Micro services become agent-accessible with zero code changes. A walkthrough using the micro/blog platform as a real-world example." +--- + +# Your Microservices Are Already an AI Platform + +*March 5, 2026 — By the Go Micro Team* + +Here's the pitch: you have microservices. They already have well-defined endpoints, typed request/response schemas, and service discovery. An AI agent needs the same things — a list of tools with input schemas and descriptions. The gap between "microservice endpoint" and "AI tool" is surprisingly small. + +With Go Micro + MCP, that gap is **zero lines of code**. + +## The Setup: A Real Blogging Platform + +We'll use [micro/blog](https://github.com/micro/blog) as our example — a real microblogging platform built on Go Micro with four services: + +- **Users** — signup, login, profiles +- **Posts** — blog posts with markdown, tags, link previews +- **Comments** — threaded comments on posts +- **Mail** — internal messaging + +These services exist today. They were built for human users interacting through a web UI. No one was thinking about AI agents when they were written. + +## One Line to Agent-Enable Everything + +```go +service := micro.New("platform", + micro.Address(":9090"), + mcp.WithMCP(":3001"), // This is it +) + +service.Handle(users) +service.Handle(posts) +service.Handle(&CommentService{}) +service.Handle(&MailService{}) +``` + +That `mcp.WithMCP(":3001")` starts an MCP gateway that: + +1. Discovers all registered handlers from the service registry +2. Converts Go method signatures into JSON tool schemas +3. Extracts descriptions from doc comments +4. Serves it all as MCP-compliant tool definitions + +No wrapper code. No API translation layer. No agent-specific handlers. + +## What the Agent Sees + +When an agent connects to `http://localhost:3001/mcp/tools`, it gets a tool list like: + +```json +{ + "tools": [ + { + "name": "platform.UserService.Signup", + "description": "Signup creates a new user account and returns a session token.", + "inputSchema": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Username (required, 3-20 characters)"}, + "password": {"type": "string", "description": "Password (required, minimum 6 characters)"} + } + } + }, + { + "name": "platform.PostService.Create", + "description": "Create publishes a new blog post.", + "inputSchema": { + "type": "object", + "properties": { + "title": {"type": "string", "description": "Post title (required)"}, + "content": {"type": "string", "description": "Post body in markdown (required)"}, + "author_id": {"type": "string", "description": "Author's user ID (required)"}, + "author_name": {"type": "string", "description": "Author's display name (required)"} + } + } + } + ] +} +``` + +The agent doesn't need to know it's talking to microservices. It just sees tools. + +## A Real Agent Workflow + +Here's what happens when you tell an agent: *"Sign up a new user called carol, write a post about Go concurrency, tag it, and send alice a mail about it."* + +The agent figures out the sequence on its own: + +**Step 1: Sign up** +```json +→ platform.UserService.Signup {"name": "carol", "password": "welcome123"} +← {"user": {"id": "user-3", "name": "carol"}, "token": "abc123..."} +``` + +**Step 2: Write the post** (using the returned user ID) +```json +→ platform.PostService.Create { + "title": "Go Concurrency Patterns", + "content": "Go's concurrency model is built on goroutines and channels...", + "author_id": "user-3", + "author_name": "carol" + } +← {"post": {"id": "post-2", "title": "Go Concurrency Patterns", ...}} +``` + +**Step 3: Tag it** (using the returned post ID) +```json +→ platform.PostService.TagPost {"post_id": "post-2", "tag": "golang"} +→ platform.PostService.TagPost {"post_id": "post-2", "tag": "concurrency"} +``` + +**Step 4: Notify alice** +```json +→ platform.MailService.Send { + "from": "carol", + "to": "alice", + "subject": "New post: Go Concurrency Patterns", + "body": "Hi Alice, I just published a post about Go concurrency..." + } +``` + +No orchestration engine. No workflow definition. The agent reads the tool descriptions, understands the data flow (signup returns a user ID, create returns a post ID), and chains the calls naturally. + +## Why Doc Comments Matter + +The agent's ability to chain these calls correctly comes from good descriptions. Compare: + +```go +// Bad: agent doesn't know what this returns or when to use it +func (s *UserService) Signup(ctx context.Context, req *SignupRequest, rsp *SignupResponse) error { + +// Good: agent knows the purpose, constraints, and return value +// Signup creates a new user account and returns a session token. +// The username must be unique. Use the returned token for authenticated operations. +// +// @example {"name": "alice", "password": "secret123"} +func (s *UserService) Signup(ctx context.Context, req *SignupRequest, rsp *SignupResponse) error { +``` + +The `@example` tag is especially valuable — it gives the agent a concrete input to work from, reducing errors and hallucinated field names. + +Similarly, `description` struct tags on request/response fields tell the agent what each parameter means: + +```go +type CreatePostRequest struct { + Title string `json:"title" description:"Post title (required)"` + Content string `json:"content" description:"Post body in markdown (required)"` + AuthorID string `json:"author_id" description:"Author's user ID (required)"` + AuthorName string `json:"author_name" description:"Author's display name (required)"` +} +``` + +## Adding MCP to Existing Services + +If you already have Go Micro services running (like micro/blog), you have three options: + +### Option 1: One-line in your service +```go +service := micro.New("blog", + mcp.WithMCP(":3001"), // Add this line +) +``` + +### Option 2: Standalone gateway binary +```bash +micro-mcp-gateway --registry consul:8500 --address :3001 +``` + +### Option 3: Sidecar in your deployment +```yaml +# docker-compose.yml +services: + blog: + image: micro/blog + mcp-gateway: + image: micro/mcp-gateway + environment: + - REGISTRY=consul:8500 + ports: + - "3001:3001" +``` + +All three discover services from the same registry. Zero changes to your service code. + +## Production Considerations + +The MCP gateway includes everything you need for production: + +- **Auth & Scopes** — per-tool permissions with JWT tokens +- **Rate Limiting** — token bucket per tool +- **Circuit Breakers** — protect downstream services from cascading failures +- **Audit Logging** — immutable records of every tool call +- **OpenTelemetry** — full span instrumentation with trace context propagation + +```go +mcp.WithMCP(":3001", + mcp.WithAuth(jwtProvider), + mcp.WithRateLimit(100, 20), + mcp.WithCircuitBreaker(5, 30*time.Second), + mcp.WithAudit(auditLogger), +) +``` + +## Try It + +```bash +cd examples/mcp/platform +go run . +``` + +Then point any MCP-compatible agent at `http://localhost:3001/mcp/tools` and start talking to your services. + +The full example is at [`examples/mcp/platform/`](https://github.com/micro/go-micro/tree/master/examples/mcp/platform). + +## What's Next + +We're working on a Kubernetes operator that automatically deploys MCP gateways alongside your services, request/response caching to reduce redundant calls from agents, and multi-tenant namespace isolation. See the [roadmap](/docs/roadmap-2026) for details. + +The core idea is simple: microservices already have the right structure for AI tools. We just needed to bridge the protocol gap. With MCP, that bridge is one line of code. diff --git a/internal/website/blog/index.html b/internal/website/blog/index.html index ef535ebadc..28d5a2caf8 100644 --- a/internal/website/blog/index.html +++ b/internal/website/blog/index.html @@ -10,6 +10,13 @@

Go Micro Blog

+ +

The Model Package: Client, Server, and Now Data

March 4, 2026

From 74baa6ddaa520e0b2b8af2b648166c8e7b91ea83 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Mar 2026 08:52:36 +0000 Subject: [PATCH 2/5] refactor: rename handler types to drop redundant Service suffix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UserService → Users, PostService → Posts, CommentService → Comments, MailService → Mail. Matches micro/blog naming convention. https://claude.ai/code/session_01GkduEhcrqcG45rdfYh8dAc --- examples/mcp/platform/main.go | 70 +++++++++++++++++------------------ internal/website/blog/7.md | 22 +++++------ 2 files changed, 44 insertions(+), 48 deletions(-) diff --git a/examples/mcp/platform/main.go b/examples/mcp/platform/main.go index 4226d17911..c4651240e3 100644 --- a/examples/mcp/platform/main.go +++ b/examples/mcp/platform/main.go @@ -28,7 +28,6 @@ import ( "context" "crypto/rand" "encoding/hex" - "encoding/json" "fmt" "log" "sort" @@ -90,7 +89,7 @@ type ListUsersResponse struct { Users []*User `json:"users" description:"All registered users"` } -type UserService struct { +type Users struct { mu sync.RWMutex users map[string]*User passwords map[string]string // name -> password (plaintext for demo only) @@ -98,8 +97,8 @@ type UserService struct { nextID int } -func NewUserService() *UserService { - return &UserService{ +func NewUsers() *Users { + return &Users{ users: make(map[string]*User), passwords: make(map[string]string), tokens: make(map[string]string), @@ -110,7 +109,7 @@ func NewUserService() *UserService { // The username must be unique. Use the returned token for authenticated operations. // // @example {"name": "alice", "password": "secret123"} -func (s *UserService) Signup(ctx context.Context, req *SignupRequest, rsp *SignupResponse) error { +func (s *Users) Signup(ctx context.Context, req *SignupRequest, rsp *SignupResponse) error { if req.Name == "" || len(req.Name) < 3 { return fmt.Errorf("name must be at least 3 characters") } @@ -149,7 +148,7 @@ func (s *UserService) Signup(ctx context.Context, req *SignupRequest, rsp *Signu // Returns an error if the credentials are invalid. // // @example {"name": "alice", "password": "secret123"} -func (s *UserService) Login(ctx context.Context, req *LoginRequest, rsp *LoginResponse) error { +func (s *Users) Login(ctx context.Context, req *LoginRequest, rsp *LoginResponse) error { s.mu.RLock() defer s.mu.RUnlock() @@ -174,7 +173,7 @@ func (s *UserService) Login(ctx context.Context, req *LoginRequest, rsp *LoginRe // GetProfile retrieves a user's public profile by ID. // // @example {"id": "user-1"} -func (s *UserService) GetProfile(ctx context.Context, req *GetProfileRequest, rsp *GetProfileResponse) error { +func (s *Users) GetProfile(ctx context.Context, req *GetProfileRequest, rsp *GetProfileResponse) error { s.mu.RLock() defer s.mu.RUnlock() @@ -189,7 +188,7 @@ func (s *UserService) GetProfile(ctx context.Context, req *GetProfileRequest, rs // UpdateStatus sets a user's bio or status message. // // @example {"id": "user-1", "status": "Writing about Go and microservices"} -func (s *UserService) UpdateStatus(ctx context.Context, req *UpdateStatusRequest, rsp *UpdateStatusResponse) error { +func (s *Users) UpdateStatus(ctx context.Context, req *UpdateStatusRequest, rsp *UpdateStatusResponse) error { s.mu.Lock() defer s.mu.Unlock() @@ -205,7 +204,7 @@ func (s *UserService) UpdateStatus(ctx context.Context, req *UpdateStatusRequest // List returns all registered users on the platform. // // @example {} -func (s *UserService) List(ctx context.Context, req *ListUsersRequest, rsp *ListUsersResponse) error { +func (s *Users) List(ctx context.Context, req *ListUsersRequest, rsp *ListUsersResponse) error { s.mu.RLock() defer s.mu.RUnlock() @@ -292,21 +291,21 @@ type ListTagsResponse struct { Tags []string `json:"tags" description:"All tags in use, sorted alphabetically"` } -type PostService struct { +type Posts struct { mu sync.RWMutex posts map[string]*Post nextID int } -func NewPostService() *PostService { - return &PostService{posts: make(map[string]*Post)} +func NewPosts() *Posts { + return &Posts{posts: make(map[string]*Post)} } // Create publishes a new blog post. Title, content, author_id, and author_name // are required. Content supports markdown formatting. // // @example {"title": "Getting Started with Go Micro", "content": "Go Micro makes it easy to build microservices...", "author_id": "user-1", "author_name": "alice"} -func (s *PostService) Create(ctx context.Context, req *CreatePostRequest, rsp *CreatePostResponse) error { +func (s *Posts) Create(ctx context.Context, req *CreatePostRequest, rsp *CreatePostResponse) error { if req.Title == "" { return fmt.Errorf("title is required") } @@ -339,7 +338,7 @@ func (s *PostService) Create(ctx context.Context, req *CreatePostRequest, rsp *C // Read retrieves a single blog post by ID. // // @example {"id": "post-1"} -func (s *PostService) Read(ctx context.Context, req *ReadPostRequest, rsp *ReadPostResponse) error { +func (s *Posts) Read(ctx context.Context, req *ReadPostRequest, rsp *ReadPostResponse) error { s.mu.RLock() defer s.mu.RUnlock() @@ -355,7 +354,7 @@ func (s *PostService) Read(ctx context.Context, req *ReadPostRequest, rsp *ReadP // Only non-empty fields are updated. // // @example {"id": "post-1", "title": "Updated Title", "content": "New content here..."} -func (s *PostService) Update(ctx context.Context, req *UpdatePostRequest, rsp *UpdatePostResponse) error { +func (s *Posts) Update(ctx context.Context, req *UpdatePostRequest, rsp *UpdatePostResponse) error { s.mu.Lock() defer s.mu.Unlock() @@ -377,7 +376,7 @@ func (s *PostService) Update(ctx context.Context, req *UpdatePostRequest, rsp *U // Delete removes a blog post permanently. // // @example {"id": "post-1"} -func (s *PostService) Delete(ctx context.Context, req *DeletePostRequest, rsp *DeletePostResponse) error { +func (s *Posts) Delete(ctx context.Context, req *DeletePostRequest, rsp *DeletePostResponse) error { s.mu.Lock() defer s.mu.Unlock() @@ -393,7 +392,7 @@ func (s *PostService) Delete(ctx context.Context, req *DeletePostRequest, rsp *D // Optionally filter by author_id to see a specific user's posts. // // @example {"author_id": "user-1"} -func (s *PostService) List(ctx context.Context, req *ListPostsRequest, rsp *ListPostsResponse) error { +func (s *Posts) List(ctx context.Context, req *ListPostsRequest, rsp *ListPostsResponse) error { s.mu.RLock() defer s.mu.RUnlock() @@ -414,7 +413,7 @@ func (s *PostService) List(ctx context.Context, req *ListPostsRequest, rsp *List // and discovery. Duplicate tags are ignored. // // @example {"post_id": "post-1", "tag": "golang"} -func (s *PostService) TagPost(ctx context.Context, req *TagPostRequest, rsp *TagPostResponse) error { +func (s *Posts) TagPost(ctx context.Context, req *TagPostRequest, rsp *TagPostResponse) error { if req.PostID == "" || req.Tag == "" { return fmt.Errorf("post_id and tag are required") } @@ -443,7 +442,7 @@ func (s *PostService) TagPost(ctx context.Context, req *TagPostRequest, rsp *Tag // UntagPost removes a tag from a post. // // @example {"post_id": "post-1", "tag": "golang"} -func (s *PostService) UntagPost(ctx context.Context, req *UntagPostRequest, rsp *UntagPostResponse) error { +func (s *Posts) UntagPost(ctx context.Context, req *UntagPostRequest, rsp *UntagPostResponse) error { s.mu.Lock() defer s.mu.Unlock() @@ -467,7 +466,7 @@ func (s *PostService) UntagPost(ctx context.Context, req *UntagPostRequest, rsp // ListTags returns all tags currently in use across all posts. // // @example {} -func (s *PostService) ListTags(ctx context.Context, req *ListTagsRequest, rsp *ListTagsResponse) error { +func (s *Posts) ListTags(ctx context.Context, req *ListTagsRequest, rsp *ListTagsResponse) error { s.mu.RLock() defer s.mu.RUnlock() @@ -522,7 +521,7 @@ type DeleteCommentResponse struct { Message string `json:"message" description:"Confirmation message"` } -type CommentService struct { +type Comments struct { mu sync.RWMutex comments []*Comment nextID int @@ -532,7 +531,7 @@ type CommentService struct { // and author_name are all required. // // @example {"post_id": "post-1", "content": "Great article! Very helpful.", "author_id": "user-2", "author_name": "bob"} -func (s *CommentService) Create(ctx context.Context, req *CreateCommentRequest, rsp *CreateCommentResponse) error { +func (s *Comments) Create(ctx context.Context, req *CreateCommentRequest, rsp *CreateCommentResponse) error { if req.PostID == "" { return fmt.Errorf("post_id is required") } @@ -564,7 +563,7 @@ func (s *CommentService) Create(ctx context.Context, req *CreateCommentRequest, // Use post_id to get all comments on a specific post. // // @example {"post_id": "post-1"} -func (s *CommentService) List(ctx context.Context, req *ListCommentsRequest, rsp *ListCommentsResponse) error { +func (s *Comments) List(ctx context.Context, req *ListCommentsRequest, rsp *ListCommentsResponse) error { s.mu.RLock() defer s.mu.RUnlock() @@ -583,7 +582,7 @@ func (s *CommentService) List(ctx context.Context, req *ListCommentsRequest, rsp // Delete removes a comment by ID. // // @example {"id": "comment-1"} -func (s *CommentService) Delete(ctx context.Context, req *DeleteCommentRequest, rsp *DeleteCommentResponse) error { +func (s *Comments) Delete(ctx context.Context, req *DeleteCommentRequest, rsp *DeleteCommentResponse) error { s.mu.Lock() defer s.mu.Unlock() @@ -628,7 +627,7 @@ type ReadMailResponse struct { Messages []*MailMessage `json:"messages" description:"Inbox messages, newest first"` } -type MailService struct { +type Mail struct { mu sync.RWMutex messages []*MailMessage nextID int @@ -638,7 +637,7 @@ type MailService struct { // Both sender and recipient are identified by username. // // @example {"from": "alice", "to": "bob", "subject": "Welcome!", "body": "Hey Bob, welcome to the platform!"} -func (s *MailService) Send(ctx context.Context, req *SendMailRequest, rsp *SendMailResponse) error { +func (s *Mail) Send(ctx context.Context, req *SendMailRequest, rsp *SendMailResponse) error { if req.From == "" || req.To == "" { return fmt.Errorf("from and to are required") } @@ -666,7 +665,7 @@ func (s *MailService) Send(ctx context.Context, req *SendMailRequest, rsp *SendM // Read returns all messages in a user's inbox, newest first. // // @example {"user": "alice"} -func (s *MailService) Read(ctx context.Context, req *ReadMailRequest, rsp *ReadMailResponse) error { +func (s *Mail) Read(ctx context.Context, req *ReadMailRequest, rsp *ReadMailResponse) error { if req.User == "" { return fmt.Errorf("user is required") } @@ -694,18 +693,18 @@ func main() { ) service.Init() - users := NewUserService() - posts := NewPostService() + users := NewUsers() + posts := NewPosts() // Seed some demo data so agents have something to work with seedData(users, posts) service.Handle(users) service.Handle(posts) - service.Handle(&CommentService{}) - service.Handle(&MailService{}, - server.WithEndpointScopes("MailService.Send", "mail:write"), - server.WithEndpointScopes("MailService.Read", "mail:read"), + service.Handle(&Comments{}) + service.Handle(&Mail{}, + server.WithEndpointScopes("Mail.Send", "mail:write"), + server.WithEndpointScopes("Mail.Read", "mail:read"), ) printBanner() @@ -715,7 +714,7 @@ func main() { } } -func seedData(users *UserService, posts *PostService) { +func seedData(users *Users, posts *Posts) { // Create demo users var aliceRsp SignupResponse users.Signup(context.Background(), &SignupRequest{ @@ -773,6 +772,3 @@ func generateToken() string { rand.Read(b) return hex.EncodeToString(b) } - -// ensure json import is used (for seed data marshaling in tests) -var _ = json.Marshal diff --git a/internal/website/blog/7.md b/internal/website/blog/7.md index 8b809bad0d..3581b4634c 100644 --- a/internal/website/blog/7.md +++ b/internal/website/blog/7.md @@ -34,8 +34,8 @@ service := micro.New("platform", service.Handle(users) service.Handle(posts) -service.Handle(&CommentService{}) -service.Handle(&MailService{}) +service.Handle(&Comments{}) +service.Handle(&Mail{}) ``` That `mcp.WithMCP(":3001")` starts an MCP gateway that: @@ -55,7 +55,7 @@ When an agent connects to `http://localhost:3001/mcp/tools`, it gets a tool list { "tools": [ { - "name": "platform.UserService.Signup", + "name": "platform.Users.Signup", "description": "Signup creates a new user account and returns a session token.", "inputSchema": { "type": "object", @@ -66,7 +66,7 @@ When an agent connects to `http://localhost:3001/mcp/tools`, it gets a tool list } }, { - "name": "platform.PostService.Create", + "name": "platform.Posts.Create", "description": "Create publishes a new blog post.", "inputSchema": { "type": "object", @@ -92,13 +92,13 @@ The agent figures out the sequence on its own: **Step 1: Sign up** ```json -→ platform.UserService.Signup {"name": "carol", "password": "welcome123"} +→ platform.Users.Signup {"name": "carol", "password": "welcome123"} ← {"user": {"id": "user-3", "name": "carol"}, "token": "abc123..."} ``` **Step 2: Write the post** (using the returned user ID) ```json -→ platform.PostService.Create { +→ platform.Posts.Create { "title": "Go Concurrency Patterns", "content": "Go's concurrency model is built on goroutines and channels...", "author_id": "user-3", @@ -109,13 +109,13 @@ The agent figures out the sequence on its own: **Step 3: Tag it** (using the returned post ID) ```json -→ platform.PostService.TagPost {"post_id": "post-2", "tag": "golang"} -→ platform.PostService.TagPost {"post_id": "post-2", "tag": "concurrency"} +→ platform.Posts.TagPost {"post_id": "post-2", "tag": "golang"} +→ platform.Posts.TagPost {"post_id": "post-2", "tag": "concurrency"} ``` **Step 4: Notify alice** ```json -→ platform.MailService.Send { +→ platform.Mail.Send { "from": "carol", "to": "alice", "subject": "New post: Go Concurrency Patterns", @@ -131,14 +131,14 @@ The agent's ability to chain these calls correctly comes from good descriptions. ```go // Bad: agent doesn't know what this returns or when to use it -func (s *UserService) Signup(ctx context.Context, req *SignupRequest, rsp *SignupResponse) error { +func (s *Users) Signup(ctx context.Context, req *SignupRequest, rsp *SignupResponse) error { // Good: agent knows the purpose, constraints, and return value // Signup creates a new user account and returns a session token. // The username must be unique. Use the returned token for authenticated operations. // // @example {"name": "alice", "password": "secret123"} -func (s *UserService) Signup(ctx context.Context, req *SignupRequest, rsp *SignupResponse) error { +func (s *Users) Signup(ctx context.Context, req *SignupRequest, rsp *SignupResponse) error { ``` The `@example` tag is especially valuable — it gives the agent a concrete input to work from, reducing errors and hallucinated field names. From 1c69836b451f1959f4f7059caeb5569573e2b9d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Mar 2026 09:21:05 +0000 Subject: [PATCH 3/5] refactor: consolidate top-level directories, reduce framework bloat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move internal/non-public packages behind internal/ or into their parent packages where they belong: - deploy/ → gateway/mcp/deploy/ (Helm charts belong with the gateway) - profile/ → service/profile/ (preset plugin profiles are a service concern) - scripts/ → internal/scripts/ (install script is not public API) - test/ → internal/test/ (test harness is not public API) - util/ → internal/util/ (internal helpers shouldn't be imported externally) Also fixes CLAUDE.md merge conflict markers and updates project structure documentation. All import paths updated. Build and tests pass. https://claude.ai/code/session_01GkduEhcrqcG45rdfYh8dAc --- CLAUDE.md | 25 +++---------------- broker/http.go | 6 ++--- broker/memory.go | 4 +-- broker/rabbitmq/connection.go | 2 +- client/backoff.go | 2 +- client/grpc/grpc.go | 2 +- client/rpc_client.go | 6 ++--- cmd/cmd.go | 2 +- debug/log/memory/memory.go | 2 +- debug/log/os.go | 2 +- debug/stats/default.go | 2 +- debug/trace/default.go | 2 +- .../mcp/deploy}/helm/mcp-gateway/Chart.yaml | 0 .../mcp/deploy}/helm/mcp-gateway/README.md | 0 .../helm/mcp-gateway/templates/NOTES.txt | 0 .../helm/mcp-gateway/templates/_helpers.tpl | 0 .../mcp-gateway/templates/deployment.yaml | 0 .../helm/mcp-gateway/templates/hpa.yaml | 0 .../helm/mcp-gateway/templates/ingress.yaml | 0 .../helm/mcp-gateway/templates/service.yaml | 0 .../mcp-gateway/templates/serviceaccount.yaml | 0 .../mcp/deploy}/helm/mcp-gateway/values.yaml | 0 {scripts => internal/scripts}/install.sh | 0 {test => internal/test}/service.go | 0 {test => internal/test}/testing.go | 0 {test => internal/test}/testing_test.go | 0 {util => internal/util}/addr/addr.go | 0 {util => internal/util}/addr/addr_test.go | 0 {util => internal/util}/backoff/backoff.go | 0 {util => internal/util}/buf/buf.go | 0 {util => internal/util}/grpc/grpc.go | 0 {util => internal/util}/grpc/grpc_test.go | 0 {util => internal/util}/http/http.go | 0 {util => internal/util}/http/http_test.go | 0 {util => internal/util}/http/options.go | 0 {util => internal/util}/http/roundtripper.go | 0 {util => internal/util}/jitter/jitter.go | 0 {util => internal/util}/mdns/.gitignore | 0 {util => internal/util}/mdns/client.go | 0 {util => internal/util}/mdns/dns_sd.go | 0 {util => internal/util}/mdns/dns_sd_test.go | 0 {util => internal/util}/mdns/server.go | 0 {util => internal/util}/mdns/server_test.go | 0 {util => internal/util}/mdns/zone.go | 0 {util => internal/util}/mdns/zone_test.go | 0 {util => internal/util}/net/net.go | 0 {util => internal/util}/net/net_test.go | 0 {util => internal/util}/pool/default.go | 0 {util => internal/util}/pool/default_test.go | 0 {util => internal/util}/pool/options.go | 0 {util => internal/util}/pool/pool.go | 0 {util => internal/util}/registry/util.go | 0 {util => internal/util}/registry/util_test.go | 0 {util => internal/util}/ring/buffer.go | 0 {util => internal/util}/ring/buffer_test.go | 0 {util => internal/util}/signal/signal.go | 0 {util => internal/util}/socket/pool.go | 0 {util => internal/util}/socket/socket.go | 0 {util => internal/util}/test/test.go | 0 {util => internal/util}/tls/tls.go | 0 {util => internal/util}/tls/tls_test.go | 0 {util => internal/util}/wrapper/wrapper.go | 0 .../util}/wrapper/wrapper_test.go | 0 registry/cache/cache.go | 2 +- registry/consul/consul.go | 4 +-- registry/consul/watcher.go | 4 +-- registry/etcd/etcd.go | 2 +- registry/mdns_registry.go | 2 +- server/grpc/grpc.go | 8 +++--- server/rpc_request.go | 2 +- server/rpc_server.go | 8 +++--- server/server.go | 2 +- service/group.go | 2 +- {profile => service/profile}/profile.go | 0 service/service.go | 2 +- transport/grpc/grpc.go | 6 ++--- transport/http_client.go | 2 +- transport/http_transport.go | 6 ++--- transport/memory.go | 4 +-- web/service.go | 12 ++++----- 80 files changed, 54 insertions(+), 71 deletions(-) rename {deploy => gateway/mcp/deploy}/helm/mcp-gateway/Chart.yaml (100%) rename {deploy => gateway/mcp/deploy}/helm/mcp-gateway/README.md (100%) rename {deploy => gateway/mcp/deploy}/helm/mcp-gateway/templates/NOTES.txt (100%) rename {deploy => gateway/mcp/deploy}/helm/mcp-gateway/templates/_helpers.tpl (100%) rename {deploy => gateway/mcp/deploy}/helm/mcp-gateway/templates/deployment.yaml (100%) rename {deploy => gateway/mcp/deploy}/helm/mcp-gateway/templates/hpa.yaml (100%) rename {deploy => gateway/mcp/deploy}/helm/mcp-gateway/templates/ingress.yaml (100%) rename {deploy => gateway/mcp/deploy}/helm/mcp-gateway/templates/service.yaml (100%) rename {deploy => gateway/mcp/deploy}/helm/mcp-gateway/templates/serviceaccount.yaml (100%) rename {deploy => gateway/mcp/deploy}/helm/mcp-gateway/values.yaml (100%) rename {scripts => internal/scripts}/install.sh (100%) rename {test => internal/test}/service.go (100%) rename {test => internal/test}/testing.go (100%) rename {test => internal/test}/testing_test.go (100%) rename {util => internal/util}/addr/addr.go (100%) rename {util => internal/util}/addr/addr_test.go (100%) rename {util => internal/util}/backoff/backoff.go (100%) rename {util => internal/util}/buf/buf.go (100%) rename {util => internal/util}/grpc/grpc.go (100%) rename {util => internal/util}/grpc/grpc_test.go (100%) rename {util => internal/util}/http/http.go (100%) rename {util => internal/util}/http/http_test.go (100%) rename {util => internal/util}/http/options.go (100%) rename {util => internal/util}/http/roundtripper.go (100%) rename {util => internal/util}/jitter/jitter.go (100%) rename {util => internal/util}/mdns/.gitignore (100%) rename {util => internal/util}/mdns/client.go (100%) rename {util => internal/util}/mdns/dns_sd.go (100%) rename {util => internal/util}/mdns/dns_sd_test.go (100%) rename {util => internal/util}/mdns/server.go (100%) rename {util => internal/util}/mdns/server_test.go (100%) rename {util => internal/util}/mdns/zone.go (100%) rename {util => internal/util}/mdns/zone_test.go (100%) rename {util => internal/util}/net/net.go (100%) rename {util => internal/util}/net/net_test.go (100%) rename {util => internal/util}/pool/default.go (100%) rename {util => internal/util}/pool/default_test.go (100%) rename {util => internal/util}/pool/options.go (100%) rename {util => internal/util}/pool/pool.go (100%) rename {util => internal/util}/registry/util.go (100%) rename {util => internal/util}/registry/util_test.go (100%) rename {util => internal/util}/ring/buffer.go (100%) rename {util => internal/util}/ring/buffer_test.go (100%) rename {util => internal/util}/signal/signal.go (100%) rename {util => internal/util}/socket/pool.go (100%) rename {util => internal/util}/socket/socket.go (100%) rename {util => internal/util}/test/test.go (100%) rename {util => internal/util}/tls/tls.go (100%) rename {util => internal/util}/tls/tls_test.go (100%) rename {util => internal/util}/wrapper/wrapper.go (100%) rename {util => internal/util}/wrapper/wrapper_test.go (100%) rename {profile => service/profile}/profile.go (100%) diff --git a/CLAUDE.md b/CLAUDE.md index 1ae1a62fe1..3de0970c77 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,7 @@ micro run ``` go-micro/ +├── ai/ # AI model providers (Anthropic, OpenAI) ├── auth/ # Authentication (JWT, no-op) ├── broker/ # Message broker (NATS, RabbitMQ) ├── cache/ # Caching (Redis) @@ -46,27 +47,20 @@ go-micro/ ├── gateway/ │ ├── api/ # REST API gateway │ └── mcp/ # MCP gateway (core AI integration) +│ └── deploy/ # Helm charts for MCP gateway ├── health/ # Health checking ├── logger/ # Logging ├── metadata/ # Context metadata -├── ai/ # AI model providers -│ ├── anthropic/ # Claude provider -│ └── openai/ # GPT provider ├── model/ # Typed data models (CRUD, queries, schemas) -│ ├── memory/ # In-memory backend (dev/testing) -│ ├── sqlite/ # SQLite backend (dev/single-node) -│ └── postgres/ # PostgreSQL backend (production) ├── registry/ # Service discovery (mDNS, Consul, etcd) ├── selector/ # Client-side load balancing ├── server/ # RPC server -├── service/ # Service interface +├── service/ # Service interface + profiles ├── store/ # Data persistence (Postgres, NATS KV) ├── transport/ # Network transport ├── wrapper/ # Middleware (auth, trace, metrics) -├── contrib/ # Community packages -│ └── langchain-go-micro/ # LangChain Python SDK ├── examples/ # Working examples -└── internal/website/docs/ # Documentation site source +└── internal/ # Non-public: docs, utils, test harness ``` ## Key Architectural Decisions @@ -128,29 +122,18 @@ Build compelling demos showing agents interacting with go-micro services in real | CLI Entry | `cmd/micro/main.go` | | MCP CLI | `cmd/micro/mcp/` | | Server (run/server) | `cmd/micro/server/server.go` | -<<<<<<< claude/changelog-fZd2J -| Roadmap | `ROADMAP_2026.md` | -| Status | `CURRENT_STATUS_SUMMARY.md` | -======= | Roadmap | `internal/docs/ROADMAP_2026.md` | | Status | `internal/docs/CURRENT_STATUS_SUMMARY.md` | ->>>>>>> master | Changelog | `CHANGELOG.md` | | Docs Site | `internal/website/docs/` | ## Roadmap & Status Documents - **[ROADMAP.md](ROADMAP.md)** - General framework roadmap -<<<<<<< claude/changelog-fZd2J -- **[ROADMAP_2026.md](ROADMAP_2026.md)** - AI-native era roadmap with business model -- **[CURRENT_STATUS_SUMMARY.md](CURRENT_STATUS_SUMMARY.md)** - Quick status overview -- **[PROJECT_STATUS_2026.md](PROJECT_STATUS_2026.md)** - Detailed technical status -======= - **[internal/docs/ROADMAP_2026.md](internal/docs/ROADMAP_2026.md)** - AI-native era roadmap with business model - **[internal/docs/CURRENT_STATUS_SUMMARY.md](internal/docs/CURRENT_STATUS_SUMMARY.md)** - Quick status overview - **[internal/docs/PROJECT_STATUS_2026.md](internal/docs/PROJECT_STATUS_2026.md)** - Detailed technical status - **[internal/docs/IMPLEMENTATION_SUMMARY.md](internal/docs/IMPLEMENTATION_SUMMARY.md)** - Implementation notes ->>>>>>> master - **[CHANGELOG.md](CHANGELOG.md)** - What changed and when ## Contributing diff --git a/broker/http.go b/broker/http.go index e769b02a39..8a74141cac 100644 --- a/broker/http.go +++ b/broker/http.go @@ -20,9 +20,9 @@ import ( "go-micro.dev/v5/registry" "go-micro.dev/v5/registry/cache" "go-micro.dev/v5/transport/headers" - maddr "go-micro.dev/v5/util/addr" - mnet "go-micro.dev/v5/util/net" - mls "go-micro.dev/v5/util/tls" + maddr "go-micro.dev/v5/internal/util/addr" + mnet "go-micro.dev/v5/internal/util/net" + mls "go-micro.dev/v5/internal/util/tls" "golang.org/x/net/http2" ) diff --git a/broker/memory.go b/broker/memory.go index bb6637a6df..aba227513c 100644 --- a/broker/memory.go +++ b/broker/memory.go @@ -8,8 +8,8 @@ import ( "github.com/google/uuid" log "go-micro.dev/v5/logger" - maddr "go-micro.dev/v5/util/addr" - mnet "go-micro.dev/v5/util/net" + maddr "go-micro.dev/v5/internal/util/addr" + mnet "go-micro.dev/v5/internal/util/net" ) type memoryBroker struct { diff --git a/broker/rabbitmq/connection.go b/broker/rabbitmq/connection.go index 1dca049d51..e2ae17078b 100644 --- a/broker/rabbitmq/connection.go +++ b/broker/rabbitmq/connection.go @@ -12,7 +12,7 @@ import ( amqp "github.com/rabbitmq/amqp091-go" "go-micro.dev/v5/logger" - mtls "go-micro.dev/v5/util/tls" + mtls "go-micro.dev/v5/internal/util/tls" ) type MQExchangeType string diff --git a/client/backoff.go b/client/backoff.go index 5921b4c865..3bd2567f9b 100644 --- a/client/backoff.go +++ b/client/backoff.go @@ -4,7 +4,7 @@ import ( "context" "time" - "go-micro.dev/v5/util/backoff" + "go-micro.dev/v5/internal/util/backoff" ) type BackoffFunc func(ctx context.Context, req Request, attempts int) (time.Duration, error) diff --git a/client/grpc/grpc.go b/client/grpc/grpc.go index 493f0524e3..7998922086 100644 --- a/client/grpc/grpc.go +++ b/client/grpc/grpc.go @@ -19,7 +19,7 @@ import ( "go-micro.dev/v5/metadata" "go-micro.dev/v5/registry" "go-micro.dev/v5/selector" - pnet "go-micro.dev/v5/util/net" + pnet "go-micro.dev/v5/internal/util/net" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/encoding" diff --git a/client/rpc_client.go b/client/rpc_client.go index 480eb5a405..cf80a42ca6 100644 --- a/client/rpc_client.go +++ b/client/rpc_client.go @@ -20,9 +20,9 @@ import ( "go-micro.dev/v5/selector" "go-micro.dev/v5/transport" "go-micro.dev/v5/transport/headers" - "go-micro.dev/v5/util/buf" - "go-micro.dev/v5/util/net" - "go-micro.dev/v5/util/pool" + "go-micro.dev/v5/internal/util/buf" + "go-micro.dev/v5/internal/util/net" + "go-micro.dev/v5/internal/util/pool" ) const ( diff --git a/cmd/cmd.go b/cmd/cmd.go index efbea02b64..87b15488de 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -23,7 +23,7 @@ import ( "go-micro.dev/v5/debug/trace" "go-micro.dev/v5/events" "go-micro.dev/v5/logger" - mprofile "go-micro.dev/v5/profile" + mprofile "go-micro.dev/v5/service/profile" "go-micro.dev/v5/registry" "go-micro.dev/v5/registry/consul" "go-micro.dev/v5/registry/etcd" diff --git a/debug/log/memory/memory.go b/debug/log/memory/memory.go index 9dd163335e..37bc2c73b7 100644 --- a/debug/log/memory/memory.go +++ b/debug/log/memory/memory.go @@ -5,7 +5,7 @@ import ( "fmt" "go-micro.dev/v5/debug/log" - "go-micro.dev/v5/util/ring" + "go-micro.dev/v5/internal/util/ring" ) var ( diff --git a/debug/log/os.go b/debug/log/os.go index 319985e997..40ba606a0d 100644 --- a/debug/log/os.go +++ b/debug/log/os.go @@ -4,7 +4,7 @@ import ( "sync" "github.com/google/uuid" - "go-micro.dev/v5/util/ring" + "go-micro.dev/v5/internal/util/ring" ) // Should stream from OS. diff --git a/debug/stats/default.go b/debug/stats/default.go index 4d9639f49e..5a24928972 100644 --- a/debug/stats/default.go +++ b/debug/stats/default.go @@ -5,7 +5,7 @@ import ( "sync" "time" - "go-micro.dev/v5/util/ring" + "go-micro.dev/v5/internal/util/ring" ) type stats struct { diff --git a/debug/trace/default.go b/debug/trace/default.go index 32b9f450c6..7cdedd618e 100644 --- a/debug/trace/default.go +++ b/debug/trace/default.go @@ -5,7 +5,7 @@ import ( "time" "github.com/google/uuid" - "go-micro.dev/v5/util/ring" + "go-micro.dev/v5/internal/util/ring" ) type memTracer struct { diff --git a/deploy/helm/mcp-gateway/Chart.yaml b/gateway/mcp/deploy/helm/mcp-gateway/Chart.yaml similarity index 100% rename from deploy/helm/mcp-gateway/Chart.yaml rename to gateway/mcp/deploy/helm/mcp-gateway/Chart.yaml diff --git a/deploy/helm/mcp-gateway/README.md b/gateway/mcp/deploy/helm/mcp-gateway/README.md similarity index 100% rename from deploy/helm/mcp-gateway/README.md rename to gateway/mcp/deploy/helm/mcp-gateway/README.md diff --git a/deploy/helm/mcp-gateway/templates/NOTES.txt b/gateway/mcp/deploy/helm/mcp-gateway/templates/NOTES.txt similarity index 100% rename from deploy/helm/mcp-gateway/templates/NOTES.txt rename to gateway/mcp/deploy/helm/mcp-gateway/templates/NOTES.txt diff --git a/deploy/helm/mcp-gateway/templates/_helpers.tpl b/gateway/mcp/deploy/helm/mcp-gateway/templates/_helpers.tpl similarity index 100% rename from deploy/helm/mcp-gateway/templates/_helpers.tpl rename to gateway/mcp/deploy/helm/mcp-gateway/templates/_helpers.tpl diff --git a/deploy/helm/mcp-gateway/templates/deployment.yaml b/gateway/mcp/deploy/helm/mcp-gateway/templates/deployment.yaml similarity index 100% rename from deploy/helm/mcp-gateway/templates/deployment.yaml rename to gateway/mcp/deploy/helm/mcp-gateway/templates/deployment.yaml diff --git a/deploy/helm/mcp-gateway/templates/hpa.yaml b/gateway/mcp/deploy/helm/mcp-gateway/templates/hpa.yaml similarity index 100% rename from deploy/helm/mcp-gateway/templates/hpa.yaml rename to gateway/mcp/deploy/helm/mcp-gateway/templates/hpa.yaml diff --git a/deploy/helm/mcp-gateway/templates/ingress.yaml b/gateway/mcp/deploy/helm/mcp-gateway/templates/ingress.yaml similarity index 100% rename from deploy/helm/mcp-gateway/templates/ingress.yaml rename to gateway/mcp/deploy/helm/mcp-gateway/templates/ingress.yaml diff --git a/deploy/helm/mcp-gateway/templates/service.yaml b/gateway/mcp/deploy/helm/mcp-gateway/templates/service.yaml similarity index 100% rename from deploy/helm/mcp-gateway/templates/service.yaml rename to gateway/mcp/deploy/helm/mcp-gateway/templates/service.yaml diff --git a/deploy/helm/mcp-gateway/templates/serviceaccount.yaml b/gateway/mcp/deploy/helm/mcp-gateway/templates/serviceaccount.yaml similarity index 100% rename from deploy/helm/mcp-gateway/templates/serviceaccount.yaml rename to gateway/mcp/deploy/helm/mcp-gateway/templates/serviceaccount.yaml diff --git a/deploy/helm/mcp-gateway/values.yaml b/gateway/mcp/deploy/helm/mcp-gateway/values.yaml similarity index 100% rename from deploy/helm/mcp-gateway/values.yaml rename to gateway/mcp/deploy/helm/mcp-gateway/values.yaml diff --git a/scripts/install.sh b/internal/scripts/install.sh similarity index 100% rename from scripts/install.sh rename to internal/scripts/install.sh diff --git a/test/service.go b/internal/test/service.go similarity index 100% rename from test/service.go rename to internal/test/service.go diff --git a/test/testing.go b/internal/test/testing.go similarity index 100% rename from test/testing.go rename to internal/test/testing.go diff --git a/test/testing_test.go b/internal/test/testing_test.go similarity index 100% rename from test/testing_test.go rename to internal/test/testing_test.go diff --git a/util/addr/addr.go b/internal/util/addr/addr.go similarity index 100% rename from util/addr/addr.go rename to internal/util/addr/addr.go diff --git a/util/addr/addr_test.go b/internal/util/addr/addr_test.go similarity index 100% rename from util/addr/addr_test.go rename to internal/util/addr/addr_test.go diff --git a/util/backoff/backoff.go b/internal/util/backoff/backoff.go similarity index 100% rename from util/backoff/backoff.go rename to internal/util/backoff/backoff.go diff --git a/util/buf/buf.go b/internal/util/buf/buf.go similarity index 100% rename from util/buf/buf.go rename to internal/util/buf/buf.go diff --git a/util/grpc/grpc.go b/internal/util/grpc/grpc.go similarity index 100% rename from util/grpc/grpc.go rename to internal/util/grpc/grpc.go diff --git a/util/grpc/grpc_test.go b/internal/util/grpc/grpc_test.go similarity index 100% rename from util/grpc/grpc_test.go rename to internal/util/grpc/grpc_test.go diff --git a/util/http/http.go b/internal/util/http/http.go similarity index 100% rename from util/http/http.go rename to internal/util/http/http.go diff --git a/util/http/http_test.go b/internal/util/http/http_test.go similarity index 100% rename from util/http/http_test.go rename to internal/util/http/http_test.go diff --git a/util/http/options.go b/internal/util/http/options.go similarity index 100% rename from util/http/options.go rename to internal/util/http/options.go diff --git a/util/http/roundtripper.go b/internal/util/http/roundtripper.go similarity index 100% rename from util/http/roundtripper.go rename to internal/util/http/roundtripper.go diff --git a/util/jitter/jitter.go b/internal/util/jitter/jitter.go similarity index 100% rename from util/jitter/jitter.go rename to internal/util/jitter/jitter.go diff --git a/util/mdns/.gitignore b/internal/util/mdns/.gitignore similarity index 100% rename from util/mdns/.gitignore rename to internal/util/mdns/.gitignore diff --git a/util/mdns/client.go b/internal/util/mdns/client.go similarity index 100% rename from util/mdns/client.go rename to internal/util/mdns/client.go diff --git a/util/mdns/dns_sd.go b/internal/util/mdns/dns_sd.go similarity index 100% rename from util/mdns/dns_sd.go rename to internal/util/mdns/dns_sd.go diff --git a/util/mdns/dns_sd_test.go b/internal/util/mdns/dns_sd_test.go similarity index 100% rename from util/mdns/dns_sd_test.go rename to internal/util/mdns/dns_sd_test.go diff --git a/util/mdns/server.go b/internal/util/mdns/server.go similarity index 100% rename from util/mdns/server.go rename to internal/util/mdns/server.go diff --git a/util/mdns/server_test.go b/internal/util/mdns/server_test.go similarity index 100% rename from util/mdns/server_test.go rename to internal/util/mdns/server_test.go diff --git a/util/mdns/zone.go b/internal/util/mdns/zone.go similarity index 100% rename from util/mdns/zone.go rename to internal/util/mdns/zone.go diff --git a/util/mdns/zone_test.go b/internal/util/mdns/zone_test.go similarity index 100% rename from util/mdns/zone_test.go rename to internal/util/mdns/zone_test.go diff --git a/util/net/net.go b/internal/util/net/net.go similarity index 100% rename from util/net/net.go rename to internal/util/net/net.go diff --git a/util/net/net_test.go b/internal/util/net/net_test.go similarity index 100% rename from util/net/net_test.go rename to internal/util/net/net_test.go diff --git a/util/pool/default.go b/internal/util/pool/default.go similarity index 100% rename from util/pool/default.go rename to internal/util/pool/default.go diff --git a/util/pool/default_test.go b/internal/util/pool/default_test.go similarity index 100% rename from util/pool/default_test.go rename to internal/util/pool/default_test.go diff --git a/util/pool/options.go b/internal/util/pool/options.go similarity index 100% rename from util/pool/options.go rename to internal/util/pool/options.go diff --git a/util/pool/pool.go b/internal/util/pool/pool.go similarity index 100% rename from util/pool/pool.go rename to internal/util/pool/pool.go diff --git a/util/registry/util.go b/internal/util/registry/util.go similarity index 100% rename from util/registry/util.go rename to internal/util/registry/util.go diff --git a/util/registry/util_test.go b/internal/util/registry/util_test.go similarity index 100% rename from util/registry/util_test.go rename to internal/util/registry/util_test.go diff --git a/util/ring/buffer.go b/internal/util/ring/buffer.go similarity index 100% rename from util/ring/buffer.go rename to internal/util/ring/buffer.go diff --git a/util/ring/buffer_test.go b/internal/util/ring/buffer_test.go similarity index 100% rename from util/ring/buffer_test.go rename to internal/util/ring/buffer_test.go diff --git a/util/signal/signal.go b/internal/util/signal/signal.go similarity index 100% rename from util/signal/signal.go rename to internal/util/signal/signal.go diff --git a/util/socket/pool.go b/internal/util/socket/pool.go similarity index 100% rename from util/socket/pool.go rename to internal/util/socket/pool.go diff --git a/util/socket/socket.go b/internal/util/socket/socket.go similarity index 100% rename from util/socket/socket.go rename to internal/util/socket/socket.go diff --git a/util/test/test.go b/internal/util/test/test.go similarity index 100% rename from util/test/test.go rename to internal/util/test/test.go diff --git a/util/tls/tls.go b/internal/util/tls/tls.go similarity index 100% rename from util/tls/tls.go rename to internal/util/tls/tls.go diff --git a/util/tls/tls_test.go b/internal/util/tls/tls_test.go similarity index 100% rename from util/tls/tls_test.go rename to internal/util/tls/tls_test.go diff --git a/util/wrapper/wrapper.go b/internal/util/wrapper/wrapper.go similarity index 100% rename from util/wrapper/wrapper.go rename to internal/util/wrapper/wrapper.go diff --git a/util/wrapper/wrapper_test.go b/internal/util/wrapper/wrapper_test.go similarity index 100% rename from util/wrapper/wrapper_test.go rename to internal/util/wrapper/wrapper_test.go diff --git a/registry/cache/cache.go b/registry/cache/cache.go index 9d7af74a7e..6dae8a484e 100644 --- a/registry/cache/cache.go +++ b/registry/cache/cache.go @@ -11,7 +11,7 @@ import ( log "go-micro.dev/v5/logger" "go-micro.dev/v5/registry" - util "go-micro.dev/v5/util/registry" + util "go-micro.dev/v5/internal/util/registry" ) // Cache is the registry cache interface. diff --git a/registry/consul/consul.go b/registry/consul/consul.go index 999337c5ff..b441c7248e 100644 --- a/registry/consul/consul.go +++ b/registry/consul/consul.go @@ -15,8 +15,8 @@ import ( consul "github.com/hashicorp/consul/api" hash "github.com/mitchellh/hashstructure" "go-micro.dev/v5/registry" - mnet "go-micro.dev/v5/util/net" - mtls "go-micro.dev/v5/util/tls" + mnet "go-micro.dev/v5/internal/util/net" + mtls "go-micro.dev/v5/internal/util/tls" ) type consulRegistry struct { diff --git a/registry/consul/watcher.go b/registry/consul/watcher.go index f4d8babb66..4015f8ceff 100644 --- a/registry/consul/watcher.go +++ b/registry/consul/watcher.go @@ -6,8 +6,8 @@ import ( "github.com/hashicorp/consul/api" "github.com/hashicorp/consul/api/watch" "go-micro.dev/v5/registry" - mnet "go-micro.dev/v5/util/net" - regutil "go-micro.dev/v5/util/registry" + mnet "go-micro.dev/v5/internal/util/net" + regutil "go-micro.dev/v5/internal/util/registry" ) type consulWatcher struct { diff --git a/registry/etcd/etcd.go b/registry/etcd/etcd.go index 4831661833..586de5c5e8 100644 --- a/registry/etcd/etcd.go +++ b/registry/etcd/etcd.go @@ -16,7 +16,7 @@ import ( hash "github.com/mitchellh/hashstructure" "go-micro.dev/v5/logger" "go-micro.dev/v5/registry" - mtls "go-micro.dev/v5/util/tls" + mtls "go-micro.dev/v5/internal/util/tls" "go.etcd.io/etcd/api/v3/v3rpc/rpctypes" clientv3 "go.etcd.io/etcd/client/v3" "go.uber.org/zap" diff --git a/registry/mdns_registry.go b/registry/mdns_registry.go index 002cae43dd..4c1f6f2fbb 100644 --- a/registry/mdns_registry.go +++ b/registry/mdns_registry.go @@ -17,7 +17,7 @@ import ( "github.com/google/uuid" log "go-micro.dev/v5/logger" - "go-micro.dev/v5/util/mdns" + "go-micro.dev/v5/internal/util/mdns" ) var ( diff --git a/server/grpc/grpc.go b/server/grpc/grpc.go index 3a248515d6..6ac1099c4c 100644 --- a/server/grpc/grpc.go +++ b/server/grpc/grpc.go @@ -22,10 +22,10 @@ import ( meta "go-micro.dev/v5/metadata" "go-micro.dev/v5/registry" "go-micro.dev/v5/server" - "go-micro.dev/v5/util/addr" - "go-micro.dev/v5/util/backoff" - mgrpc "go-micro.dev/v5/util/grpc" - mnet "go-micro.dev/v5/util/net" + "go-micro.dev/v5/internal/util/addr" + "go-micro.dev/v5/internal/util/backoff" + mgrpc "go-micro.dev/v5/internal/util/grpc" + mnet "go-micro.dev/v5/internal/util/net" "golang.org/x/net/netutil" "google.golang.org/grpc" diff --git a/server/rpc_request.go b/server/rpc_request.go index 386e6bd460..c4a89c4fa2 100644 --- a/server/rpc_request.go +++ b/server/rpc_request.go @@ -5,7 +5,7 @@ import ( "go-micro.dev/v5/codec" "go-micro.dev/v5/transport" - "go-micro.dev/v5/util/buf" + "go-micro.dev/v5/internal/util/buf" ) type rpcRequest struct { diff --git a/server/rpc_server.go b/server/rpc_server.go index 2b62fb1b57..300edad7c0 100644 --- a/server/rpc_server.go +++ b/server/rpc_server.go @@ -20,10 +20,10 @@ import ( "go-micro.dev/v5/registry" "go-micro.dev/v5/transport" "go-micro.dev/v5/transport/headers" - "go-micro.dev/v5/util/addr" - "go-micro.dev/v5/util/backoff" - mnet "go-micro.dev/v5/util/net" - "go-micro.dev/v5/util/socket" + "go-micro.dev/v5/internal/util/addr" + "go-micro.dev/v5/internal/util/backoff" + mnet "go-micro.dev/v5/internal/util/net" + "go-micro.dev/v5/internal/util/socket" ) type rpcServer struct { diff --git a/server/server.go b/server/server.go index 551b2642ab..59537063ef 100644 --- a/server/server.go +++ b/server/server.go @@ -12,7 +12,7 @@ import ( "go-micro.dev/v5/codec" log "go-micro.dev/v5/logger" "go-micro.dev/v5/registry" - signalutil "go-micro.dev/v5/util/signal" + signalutil "go-micro.dev/v5/internal/util/signal" ) // Server is a simple micro server abstraction. diff --git a/service/group.go b/service/group.go index f2316e24a0..0834f09ad4 100644 --- a/service/group.go +++ b/service/group.go @@ -7,7 +7,7 @@ import ( "sync" log "go-micro.dev/v5/logger" - signalutil "go-micro.dev/v5/util/signal" + signalutil "go-micro.dev/v5/internal/util/signal" ) // Group runs multiple services in a single binary with shared diff --git a/profile/profile.go b/service/profile/profile.go similarity index 100% rename from profile/profile.go rename to service/profile/profile.go diff --git a/service/service.go b/service/service.go index f6afd7fc2f..768cbc20c8 100644 --- a/service/service.go +++ b/service/service.go @@ -12,7 +12,7 @@ import ( "go-micro.dev/v5/model" "go-micro.dev/v5/server" "go-micro.dev/v5/store" - signalutil "go-micro.dev/v5/util/signal" + signalutil "go-micro.dev/v5/internal/util/signal" ) // Service is the interface for a go-micro service. diff --git a/transport/grpc/grpc.go b/transport/grpc/grpc.go index a83b3b942f..9796739cdc 100644 --- a/transport/grpc/grpc.go +++ b/transport/grpc/grpc.go @@ -8,9 +8,9 @@ import ( "go-micro.dev/v5/cmd" "go-micro.dev/v5/transport" - maddr "go-micro.dev/v5/util/addr" - mnet "go-micro.dev/v5/util/net" - mtls "go-micro.dev/v5/util/tls" + maddr "go-micro.dev/v5/internal/util/addr" + mnet "go-micro.dev/v5/internal/util/net" + mtls "go-micro.dev/v5/internal/util/tls" "google.golang.org/grpc" "google.golang.org/grpc/credentials" diff --git a/transport/http_client.go b/transport/http_client.go index bc4797d1e6..90afbbfbb6 100644 --- a/transport/http_client.go +++ b/transport/http_client.go @@ -13,7 +13,7 @@ import ( "github.com/pkg/errors" log "go-micro.dev/v5/logger" - "go-micro.dev/v5/util/buf" + "go-micro.dev/v5/internal/util/buf" ) type httpTransportClient struct { diff --git a/transport/http_transport.go b/transport/http_transport.go index baecba3c94..ad01e636c9 100644 --- a/transport/http_transport.go +++ b/transport/http_transport.go @@ -7,9 +7,9 @@ import ( "net/http" "go-micro.dev/v5/logger" - maddr "go-micro.dev/v5/util/addr" - mnet "go-micro.dev/v5/util/net" - mls "go-micro.dev/v5/util/tls" + maddr "go-micro.dev/v5/internal/util/addr" + mnet "go-micro.dev/v5/internal/util/net" + mls "go-micro.dev/v5/internal/util/tls" ) type httpTransport struct { diff --git a/transport/memory.go b/transport/memory.go index a13b25ecc6..01675d526a 100644 --- a/transport/memory.go +++ b/transport/memory.go @@ -11,8 +11,8 @@ import ( "sync" "time" - maddr "go-micro.dev/v5/util/addr" - mnet "go-micro.dev/v5/util/net" + maddr "go-micro.dev/v5/internal/util/addr" + mnet "go-micro.dev/v5/internal/util/net" ) type memorySocket struct { diff --git a/web/service.go b/web/service.go index 35357d892f..63dd1e3c52 100644 --- a/web/service.go +++ b/web/service.go @@ -16,12 +16,12 @@ import ( "go-micro.dev/v5" log "go-micro.dev/v5/logger" "go-micro.dev/v5/registry" - maddr "go-micro.dev/v5/util/addr" - "go-micro.dev/v5/util/backoff" - mhttp "go-micro.dev/v5/util/http" - mnet "go-micro.dev/v5/util/net" - signalutil "go-micro.dev/v5/util/signal" - mls "go-micro.dev/v5/util/tls" + maddr "go-micro.dev/v5/internal/util/addr" + "go-micro.dev/v5/internal/util/backoff" + mhttp "go-micro.dev/v5/internal/util/http" + mnet "go-micro.dev/v5/internal/util/net" + signalutil "go-micro.dev/v5/internal/util/signal" + mls "go-micro.dev/v5/internal/util/tls" ) type service struct { From 4391545cf2cb82fc31a0722f2f8c9adcaf2390d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Mar 2026 10:48:50 +0000 Subject: [PATCH 4/5] refactor: redesign model package to match framework conventions Rename model.Database interface to model.Model (consistent with client.Client, server.Server, store.Store). Remove generics in favor of interface{}-based API with reflection. Key changes: - model.Model interface: Register once, CRUD infers table from type - DefaultModel + NewModel() + package-level convenience functions - Schema registered via Register(&User{}), no per-call schema passing - Memory implementation as default (in model package, like store) - memory/sqlite/postgres backends updated for new interface - protoc-gen-micro generates RegisterXModel() instead of generic factory - All docs, blog, and README updated https://claude.ai/code/session_01GkduEhcrqcG45rdfYh8dAc --- README.md | 22 +- .../examples/user/user.pb.micro.go.example | 10 +- cmd/protoc-gen-micro/plugin/micro/micro.go | 10 +- internal/website/blog/6.md | 41 ++- internal/website/docs/model.md | 99 +++--- model/README.md | 67 ++-- model/memory.go | 333 ++++++++++++++++++ model/memory/memory.go | 267 +------------- model/memory/memory_test.go | 99 +++--- model/model.go | 263 +++----------- model/model_test.go | 18 +- model/options.go | 22 +- model/postgres/postgres.go | 133 +++++-- model/schema.go | 162 +++++++++ model/sqlite/sqlite.go | 137 +++++-- model/sqlite/sqlite_test.go | 107 +++--- service/options.go | 8 +- service/service.go | 6 +- 18 files changed, 1031 insertions(+), 773 deletions(-) create mode 100644 model/memory.go create mode 100644 model/schema.go diff --git a/README.md b/README.md index 8a143b197b..64f39b5a08 100644 --- a/README.md +++ b/README.md @@ -188,33 +188,37 @@ type User struct { } ``` -Create a model from the service's database and use it: +Register your types and use the model: ```go service := micro.New("users") -// Create a typed model using the service's database -users := model.New[User](service.Model()) +// Register and use the service's model backend +db := service.Model() +db.Register(&User{}) // CRUD operations -users.Create(ctx, &User{ID: "1", Name: "Alice", Email: "alice@example.com", Age: 30}) +db.Create(ctx, &User{ID: "1", Name: "Alice", Email: "alice@example.com", Age: 30}) -user, _ := users.Read(ctx, "1") +user := &User{} +db.Read(ctx, "1", user) user.Name = "Alice Smith" -users.Update(ctx, user) +db.Update(ctx, user) -users.Delete(ctx, "1") +db.Delete(ctx, "1", &User{}) ``` Query with filters, ordering, and pagination: ```go +var results []*User + // Find users by field -results, _ := users.List(ctx, model.Where("email", "alice@example.com")) +db.List(ctx, &results, model.Where("email", "alice@example.com")) // Complex queries -results, _ = users.List(ctx, +db.List(ctx, &results, model.WhereOp("age", ">=", 18), model.OrderDesc("name"), model.Limit(10), diff --git a/cmd/protoc-gen-micro/examples/user/user.pb.micro.go.example b/cmd/protoc-gen-micro/examples/user/user.pb.micro.go.example index 7469f213fd..08f84fd33a 100644 --- a/cmd/protoc-gen-micro/examples/user/user.pb.micro.go.example +++ b/cmd/protoc-gen-micro/examples/user/user.pb.micro.go.example @@ -25,7 +25,7 @@ var _ = math.Inf var _ context.Context var _ client.Option var _ server.Option -var _ model.Database +var _ model.Model // Client API for UserService service @@ -115,7 +115,7 @@ func (h *userServiceHandler) Delete(ctx context.Context, in *DeleteUserRequest, } // UserModel is a model struct generated from User. -// Use NewUserModel to create a typed model backed by any model.Database. +// Use NewUserModel to create a typed table backed by any model.Model. type UserModel struct { Id string `json:"id" model:"key"` Name string `json:"name"` @@ -124,9 +124,9 @@ type UserModel struct { Status string `json:"status"` } -// NewUserModel creates a typed model for User backed by the given database. -func NewUserModel(db model.Database) *model.Model[UserModel] { - return model.New[UserModel](db, model.WithTable("users")) +// RegisterUserModel registers the UserModel table with the given model backend. +func RegisterUserModel(db model.Model) error { + return db.Register(&UserModel{}, model.WithTable("users")) } // UserModelFromProto converts a User proto message to a UserModel. diff --git a/cmd/protoc-gen-micro/plugin/micro/micro.go b/cmd/protoc-gen-micro/plugin/micro/micro.go index a1b3820b67..a2dd7412c1 100644 --- a/cmd/protoc-gen-micro/plugin/micro/micro.go +++ b/cmd/protoc-gen-micro/plugin/micro/micro.go @@ -708,7 +708,7 @@ func (g *micro) generateModel(msg *pb.DescriptorProto, msgIndex int) { // Generate model struct g.P() g.P("// ", modelName, " is a model struct generated from ", msgName, ".") - g.P("// Use New", modelName, " to create a typed model backed by any model.Database.") + g.P("// Use New", modelName, " to create a typed table backed by any model.Model.") g.P("type ", modelName, " struct {") for _, f := range fields { tags := fmt.Sprintf("`json:%q", f.jsonName) @@ -721,10 +721,10 @@ func (g *micro) generateModel(msg *pb.DescriptorProto, msgIndex int) { g.P("}") g.P() - // Generate factory: NewXModel(db) *model.Model[XModel] - g.P("// New", modelName, " creates a typed model for ", msgName, " backed by the given database.") - g.P("func New", modelName, "(db ", modelPkg, ".Database) *", modelPkg, ".Model[", modelName, "] {") - g.P("return ", modelPkg, ".New[", modelName, "](db, ", modelPkg, `.WithTable("`, tableName, `"))`) + // Generate Register helper: RegisterXModel(db) registers the model with the given backend. + g.P("// Register", modelName, " registers the ", modelName, " table with the given model backend.") + g.P("func Register", modelName, "(db ", modelPkg, ".Model) error {") + g.P("return db.Register(&", modelName, "{}, ", modelPkg, `.WithTable("`, tableName, `"))`) g.P("}") g.P() diff --git a/internal/website/blog/6.md b/internal/website/blog/6.md index e56db838cd..78f47e3818 100644 --- a/internal/website/blog/6.md +++ b/internal/website/blog/6.md @@ -32,23 +32,25 @@ type User struct { The `model:"key"` tag marks your primary key. The `model:"index"` tag creates an index for faster queries. Column names come from `json` tags (or lowercased field names if no tag). -Create a model and use it: +Register your type and use it: ```go -users := model.New[User](service.Model()) +db := service.Model() +db.Register(&User{}) // Create -users.Create(ctx, &User{ID: "1", Name: "Alice", Email: "alice@example.com", Age: 30}) +db.Create(ctx, &User{ID: "1", Name: "Alice", Email: "alice@example.com", Age: 30}) // Read -user, err := users.Read(ctx, "1") +user := &User{} +db.Read(ctx, "1", user) // Update user.Name = "Alice Smith" -users.Update(ctx, user) +db.Update(ctx, user) // Delete -users.Delete(ctx, "1") +db.Delete(ctx, "1", &User{}) ``` No migrations. No connection setup. No configuration files. The schema is derived from your struct at startup. @@ -58,11 +60,14 @@ No migrations. No connection setup. No configuration files. The schema is derive List and count with composable query options: ```go +var active []*User + // Simple equality filter -active, _ := users.List(ctx, model.Where("email", "alice@example.com")) +db.List(ctx, &active, model.Where("email", "alice@example.com")) // Operators, ordering, pagination -page, _ := users.List(ctx, +var page []*User +db.List(ctx, &page, model.WhereOp("age", ">=", 18), model.OrderDesc("name"), model.Limit(10), @@ -70,7 +75,7 @@ page, _ := users.List(ctx, ) // Count records -total, _ := users.Count(ctx, model.Where("age", 30)) +total, _ := db.Count(ctx, &User{}, model.Where("age", 30)) ``` Filters support `=`, `!=`, `<`, `>`, `<=`, `>=`, and `LIKE`. Everything composes — add as many query options as you need. @@ -83,7 +88,8 @@ The model layer follows Go Micro's pluggable pattern. Same code, different backe ```go service := micro.New("users") -users := model.New[User](service.Model()) // in-memory by default +db := service.Model() // in-memory by default +db.Register(&User{}) ``` **SQLite** — single-file database for local development or single-node production: @@ -110,7 +116,7 @@ The Service interface now has three core accessors: type Service interface { Client() client.Client // Call other services Server() server.Server // Handle incoming requests - Model() model.Database // Save and query data + Model() model.Model // Save and query data // ... } ``` @@ -122,10 +128,11 @@ func main() { service := micro.New("users", micro.Address(":9001")) // Data layer - users := model.New[User](service.Model()) + db := service.Model() + db.Register(&User{}) // Handler with data access - service.Handle(&UserService{users: users}) + service.Handle(&UserService{db: db}) // Run service.Run() @@ -141,12 +148,12 @@ You can create multiple typed models from the same database connection: ```go db := service.Model() -users := model.New[User](db) -posts := model.New[Post](db) -comments := model.New[Comment](db) +db.Register(&User{}) +db.Register(&Post{}) +db.Register(&Comment{}) ``` -Each model gets its own table (derived from the struct name). They share the database connection. +Each type gets its own table (derived from the struct name). They share the database connection. ## What's Next diff --git a/internal/website/docs/model.md b/internal/website/docs/model.md index c89f4fda2a..a985d74632 100644 --- a/internal/website/docs/model.md +++ b/internal/website/docs/model.md @@ -2,12 +2,12 @@ layout: doc title: Data Model permalink: /docs/model.html -description: "Typed data model layer with CRUD operations, queries, and pluggable backends" +description: "Structured data model layer with CRUD operations, queries, and pluggable backends" --- # Data Model -The `model` package provides a typed data model layer for Go Micro services. Define Go structs, tag your fields, and get type-safe CRUD operations with queries, filtering, ordering, and pagination. +The `model` package provides a structured data model layer for Go Micro services. Define Go structs, tag your fields, and get CRUD operations with queries, filtering, ordering, and pagination. ## Quick Start @@ -31,26 +31,29 @@ type Task struct { func main() { service := micro.New("tasks") - // Create a typed model backed by the service's database - tasks := model.New[Task](service.Model()) + // Register your type with the service's model backend + db := service.Model() + db.Register(&Task{}) ctx := context.Background() // Create a record - tasks.Create(ctx, &Task{ID: "1", Title: "Ship it", Owner: "alice"}) + db.Create(ctx, &Task{ID: "1", Title: "Ship it", Owner: "alice"}) // Read by key - task, _ := tasks.Read(ctx, "1") + task := &Task{} + db.Read(ctx, "1", task) // Update task.Done = true - tasks.Update(ctx, task) + db.Update(ctx, task) // List with filters - aliceTasks, _ := tasks.List(ctx, model.Where("owner", "alice")) + var aliceTasks []*Task + db.List(ctx, &aliceTasks, model.Where("owner", "alice")) // Delete - tasks.Delete(ctx, "1") + db.Delete(ctx, "1", &Task{}) } ``` @@ -77,28 +80,29 @@ type User struct { CreatedAt string `json:"created_at"` } -// Auto-derived table: "users" -users := model.New[User](db) +// Register with auto-derived table: "users" +db.Register(&User{}) // Custom table name -users := model.New[User](db, model.WithTable("app_users")) +db.Register(&User{}, model.WithTable("app_users")) ``` ## CRUD Operations ```go // Create — inserts a new record (returns ErrDuplicateKey if key exists) -err := users.Create(ctx, &User{ID: "1", Name: "Alice"}) +err := db.Create(ctx, &User{ID: "1", Name: "Alice"}) // Read — retrieves by primary key (returns ErrNotFound if missing) -user, err := users.Read(ctx, "1") +user := &User{} +err = db.Read(ctx, "1", user) // Update — modifies an existing record (returns ErrNotFound if missing) user.Name = "Alice Smith" -err = users.Update(ctx, user) +err = db.Update(ctx, user) // Delete — removes by primary key (returns ErrNotFound if missing) -err = users.Delete(ctx, "1") +err = db.Delete(ctx, "1", &User{}) ``` ## Queries @@ -108,15 +112,17 @@ Use query options to filter, order, and paginate results: ### Filters ```go +var results []*User + // Equality -results, _ := users.List(ctx, model.Where("email", "alice@example.com")) +db.List(ctx, &results, model.Where("email", "alice@example.com")) // Operators: =, !=, <, >, <=, >=, LIKE -results, _ = users.List(ctx, model.WhereOp("age", ">=", 18)) -results, _ = users.List(ctx, model.WhereOp("name", "LIKE", "Ali%")) +db.List(ctx, &results, model.WhereOp("age", ">=", 18)) +db.List(ctx, &results, model.WhereOp("name", "LIKE", "Ali%")) // Multiple filters (AND) -results, _ = users.List(ctx, +db.List(ctx, &results, model.Where("owner", "alice"), model.WhereOp("age", ">", 25), ) @@ -125,14 +131,14 @@ results, _ = users.List(ctx, ### Ordering ```go -results, _ := users.List(ctx, model.OrderAsc("name")) -results, _ = users.List(ctx, model.OrderDesc("created_at")) +db.List(ctx, &results, model.OrderAsc("name")) +db.List(ctx, &results, model.OrderDesc("created_at")) ``` ### Pagination ```go -results, _ := users.List(ctx, +db.List(ctx, &results, model.Limit(10), model.Offset(20), ) @@ -141,13 +147,13 @@ results, _ := users.List(ctx, ### Counting ```go -total, _ := users.Count(ctx) -active, _ := users.Count(ctx, model.Where("active", true)) +total, _ := db.Count(ctx, &User{}) +active, _ := db.Count(ctx, &User{}, model.Where("active", true)) ``` ## Backends -The model layer uses Go Micro's pluggable interface pattern. All backends implement `model.Database`. +The model layer uses Go Micro's pluggable interface pattern. All backends implement `model.Model`. ### Memory (Default) @@ -155,16 +161,17 @@ Zero-config, in-memory storage. Data doesn't persist across restarts. Ideal for ```go service := micro.New("myservice") -tasks := model.New[Task](service.Model()) // memory backend by default +db := service.Model() // memory backend by default +db.Register(&Task{}) ``` Or create directly: ```go -import "go-micro.dev/v5/model/memory" +import "go-micro.dev/v5/model" -db := memory.New() -tasks := model.New[Task](db) +db := model.NewModel() +db.Register(&Task{}) ``` ### SQLite @@ -174,7 +181,7 @@ File-based database. Good for local development or single-node production. ```go import "go-micro.dev/v5/model/sqlite" -db, err := sqlite.New(model.WithDSN("file:app.db")) +db := sqlite.New("app.db") service := micro.New("myservice", micro.Model(db)) ``` @@ -185,7 +192,7 @@ Production-grade with connection pooling. ```go import "go-micro.dev/v5/model/postgres" -db, err := postgres.New(model.WithDSN("postgres://user:pass@localhost/myapp?sslmode=disable")) +db := postgres.New("postgres://user:pass@localhost/myapp?sslmode=disable") service := micro.New("myservice", micro.Model(db)) ``` @@ -201,12 +208,12 @@ client := service.Client() // Call other services server := service.Server() // Handle requests db := service.Model() // Data persistence -// Create typed models from the shared database -users := model.New[User](db) -posts := model.New[Post](db) +// Register your types +db.Register(&User{}) +db.Register(&Post{}) // Use in your handler -service.Handle(&UserHandler{users: users, posts: posts}) +service.Handle(&UserHandler{db: db}) service.Run() ``` @@ -214,15 +221,15 @@ A handler that uses all three: ```go type OrderHandler struct { - orders *model.Model[Order] - client client.Client + db model.Model + client client.Client } // CreateOrder saves an order and notifies the shipping service func (h *OrderHandler) CreateOrder(ctx context.Context, req *CreateReq, rsp *CreateRsp) error { // Save to database via Model order := &Order{ID: req.ID, Item: req.Item, Status: "pending"} - if err := h.orders.Create(ctx, order); err != nil { + if err := h.db.Create(ctx, order); err != nil { return err } @@ -236,20 +243,20 @@ func (h *OrderHandler) CreateOrder(ctx context.Context, req *CreateReq, rsp *Cre ## Error Handling -The model package returns two sentinel errors: +The model package returns sentinel errors: ```go import "go-micro.dev/v5/model" // Check for not found -user, err := users.Read(ctx, "missing") +err := db.Read(ctx, "missing", &User{}) if errors.Is(err, model.ErrNotFound) { // record doesn't exist } // Check for duplicate key -err = users.Create(ctx, &User{ID: "1", Name: "Alice"}) -err = users.Create(ctx, &User{ID: "1", Name: "Bob"}) +err = db.Create(ctx, &User{ID: "1", Name: "Alice"}) +err = db.Create(ctx, &User{ID: "1", Name: "Bob"}) if errors.Is(err, model.ErrDuplicateKey) { // key "1" already exists } @@ -261,12 +268,12 @@ Follow the standard Go Micro pattern — use in-memory for development, swap to ```go func main() { - var db model.Database + var db model.Model if os.Getenv("ENV") == "production" { - db, _ = postgres.New(model.WithDSN(os.Getenv("DATABASE_URL"))) + db = postgres.New(os.Getenv("DATABASE_URL")) } else { - db = memory.New() + db = model.NewModel() } service := micro.New("myservice", micro.Model(db)) diff --git a/model/README.md b/model/README.md index ee3a3cfbb5..c581e44383 100644 --- a/model/README.md +++ b/model/README.md @@ -1,6 +1,6 @@ # Model Package -The `model` package provides a typed data model layer with CRUD operations, query filtering, and multiple database backends. It uses Go generics for type-safe access. +The `model` package provides a structured data storage interface with CRUD operations, query filtering, and multiple database backends. Unlike the `store` package (which is a raw KV abstraction), `model` provides structured data access with schema awareness, WHERE queries, ordering, pagination, and indexes. @@ -10,7 +10,6 @@ Unlike the `store` package (which is a raw KV abstraction), `model` provides str import ( "context" "go-micro.dev/v5/model" - "go-micro.dev/v5/model/memory" ) // Define your model with struct tags @@ -21,25 +20,26 @@ type User struct { Age int `json:"age"` } -// Create a database and model -db := memory.New() -users := model.New[User](db) +// Create a model and register your type +db := model.NewModel() +db.Register(&User{}) ctx := context.Background() // Create -users.Create(ctx, &User{ID: "1", Name: "Alice", Email: "alice@example.com", Age: 30}) +db.Create(ctx, &User{ID: "1", Name: "Alice", Email: "alice@example.com", Age: 30}) // Read -user, _ := users.Read(ctx, "1") +user := &User{} +db.Read(ctx, "1", user) fmt.Println(user.Name) // "Alice" // Update user.Name = "Alice Smith" -users.Update(ctx, user) +db.Update(ctx, user) // Delete -users.Delete(ctx, "1") +db.Delete(ctx, "1", &User{}) ``` ## Struct Tags @@ -56,21 +56,22 @@ If no `model:"key"` tag is found, the package defaults to a field with `json:"id ```go // Filter by field value -users.List(ctx, model.Where("name", "Alice")) +var users []*User +db.List(ctx, &users, model.Where("name", "Alice")) // Comparison operators -users.List(ctx, model.WhereOp("age", ">", 25)) -users.List(ctx, model.WhereOp("name", "LIKE", "Ali%")) +db.List(ctx, &users, model.WhereOp("age", ">", 25)) +db.List(ctx, &users, model.WhereOp("name", "LIKE", "Ali%")) // Ordering -users.List(ctx, model.OrderAsc("name")) -users.List(ctx, model.OrderDesc("age")) +db.List(ctx, &users, model.OrderAsc("name")) +db.List(ctx, &users, model.OrderDesc("age")) // Pagination -users.List(ctx, model.Limit(10), model.Offset(20)) +db.List(ctx, &users, model.Limit(10), model.Offset(20)) // Combine -users.List(ctx, +db.List(ctx, &users, model.Where("status", "active"), model.WhereOp("age", ">=", 18), model.OrderDesc("created_at"), @@ -78,8 +79,8 @@ users.List(ctx, ) // Count -total, _ := users.Count(ctx) -active, _ := users.Count(ctx, model.Where("status", "active")) +total, _ := db.Count(ctx, &User{}) +active, _ := db.Count(ctx, &User{}, model.Where("status", "active")) ``` ## Backends @@ -87,9 +88,9 @@ active, _ := users.Count(ctx, model.Where("status", "active")) ### Memory (Development & Testing) ```go -import "go-micro.dev/v5/model/memory" +import "go-micro.dev/v5/model" -db := memory.New() +db := model.NewModel() ``` In-memory storage. No persistence. Fast. Good for tests and prototyping. @@ -117,26 +118,26 @@ Full PostgreSQL support. Best for production with rich query capabilities. ## Table Names -By default, the table name is the lowercase struct name + "s" (e.g., `User` → `users`). Override with `WithTable`: +By default, the table name is the lowercase struct name + "s" (e.g., `User` → `users`). Override with `model.WithTable`: ```go -users := model.New[User](db, model.WithTable("app_users")) +db.Register(&User{}, model.WithTable("app_users")) ``` -## Database Interface +## Model Interface -All backends implement the `model.Database` interface: +All backends implement the `model.Model` interface: ```go -type Database interface { +type Model interface { Init(...Option) error - NewTable(schema *Schema) error - Create(ctx context.Context, schema *Schema, key string, fields map[string]any) error - Read(ctx context.Context, schema *Schema, key string) (map[string]any, error) - Update(ctx context.Context, schema *Schema, key string, fields map[string]any) error - Delete(ctx context.Context, schema *Schema, key string) error - List(ctx context.Context, schema *Schema, opts ...QueryOption) ([]map[string]any, error) - Count(ctx context.Context, schema *Schema, opts ...QueryOption) (int64, error) + Register(v interface{}, opts ...RegisterOption) error + Create(ctx context.Context, v interface{}) error + Read(ctx context.Context, key string, v interface{}) error + Update(ctx context.Context, v interface{}) error + Delete(ctx context.Context, key string, v interface{}) error + List(ctx context.Context, result interface{}, opts ...QueryOption) error + Count(ctx context.Context, v interface{}, opts ...QueryOption) (int64, error) Close() error String() string } @@ -146,7 +147,7 @@ type Database interface { | Feature | `store` | `model` | |---------|---------|---------| -| Data format | Raw `[]byte` | Typed Go structs | +| Data format | Raw `[]byte` | Go structs | | Queries | Key prefix/suffix only | WHERE, operators, LIKE | | Ordering | None | ORDER BY field ASC/DESC | | Pagination | Limit/Offset on keys | Limit/Offset on results | diff --git a/model/memory.go b/model/memory.go new file mode 100644 index 0000000000..ab9a5feb8d --- /dev/null +++ b/model/memory.go @@ -0,0 +1,333 @@ +package model + +import ( + "context" + "fmt" + "reflect" + "strings" + "sync" +) + +type memoryModel struct { + mu sync.RWMutex + schemas map[string]*Schema + types map[reflect.Type]*Schema + tables map[string]map[string]map[string]any // table -> key -> fields +} + +func newMemoryModel(opts ...Option) Model { + return &memoryModel{ + schemas: make(map[string]*Schema), + types: make(map[reflect.Type]*Schema), + tables: make(map[string]map[string]map[string]any), + } +} + +func (m *memoryModel) Init(opts ...Option) error { + return nil +} + +func (m *memoryModel) Register(v interface{}, opts ...RegisterOption) error { + schema := BuildSchema(v, opts...) + t := ResolveType(v) + + m.mu.Lock() + defer m.mu.Unlock() + + m.schemas[schema.Table] = schema + m.types[t] = schema + if _, ok := m.tables[schema.Table]; !ok { + m.tables[schema.Table] = make(map[string]map[string]any) + } + return nil +} + +func (m *memoryModel) schema(v interface{}) (*Schema, error) { + t := ResolveType(v) + m.mu.RLock() + s, ok := m.types[t] + m.mu.RUnlock() + if !ok { + return nil, ErrNotRegistered + } + return s, nil +} + +func (m *memoryModel) Create(ctx context.Context, v interface{}) error { + schema, err := m.schema(v) + if err != nil { + return err + } + fields := StructToMap(schema, v) + key := KeyValue(schema, v) + if key == "" { + return fmt.Errorf("model: key field %q not set", schema.Key) + } + + m.mu.Lock() + defer m.mu.Unlock() + + tbl := m.tables[schema.Table] + if _, exists := tbl[key]; exists { + return ErrDuplicateKey + } + row := make(map[string]any, len(fields)) + for k, v := range fields { + row[k] = v + } + tbl[key] = row + return nil +} + +func (m *memoryModel) Read(ctx context.Context, key string, v interface{}) error { + schema, err := m.schema(v) + if err != nil { + return err + } + + m.mu.RLock() + defer m.mu.RUnlock() + + tbl := m.tables[schema.Table] + row, ok := tbl[key] + if !ok { + return ErrNotFound + } + MapToStruct(schema, row, v) + return nil +} + +func (m *memoryModel) Update(ctx context.Context, v interface{}) error { + schema, err := m.schema(v) + if err != nil { + return err + } + fields := StructToMap(schema, v) + key := KeyValue(schema, v) + if key == "" { + return fmt.Errorf("model: key field %q not set", schema.Key) + } + + m.mu.Lock() + defer m.mu.Unlock() + + tbl := m.tables[schema.Table] + if _, ok := tbl[key]; !ok { + return ErrNotFound + } + row := make(map[string]any, len(fields)) + for k, v := range fields { + row[k] = v + } + tbl[key] = row + return nil +} + +func (m *memoryModel) Delete(ctx context.Context, key string, v interface{}) error { + schema, err := m.schema(v) + if err != nil { + return err + } + + m.mu.Lock() + defer m.mu.Unlock() + + tbl := m.tables[schema.Table] + if _, ok := tbl[key]; !ok { + return ErrNotFound + } + delete(tbl, key) + return nil +} + +func (m *memoryModel) List(ctx context.Context, result interface{}, opts ...QueryOption) error { + // result must be *[]*T + rv := reflect.ValueOf(result) + if rv.Kind() != reflect.Ptr || rv.Elem().Kind() != reflect.Slice { + return fmt.Errorf("model: result must be a pointer to a slice") + } + sliceVal := rv.Elem() + elemType := sliceVal.Type().Elem() // *T + structType := elemType + if structType.Kind() == reflect.Ptr { + structType = structType.Elem() + } + + m.mu.RLock() + s, ok := m.types[structType] + m.mu.RUnlock() + if !ok { + return ErrNotRegistered + } + + q := ApplyQueryOptions(opts...) + + m.mu.RLock() + tbl := m.tables[s.Table] + + var rows []map[string]any + for _, row := range tbl { + if matchFilters(row, q.Filters) { + cp := make(map[string]any, len(row)) + for k, v := range row { + cp[k] = v + } + rows = append(rows, cp) + } + } + m.mu.RUnlock() + + if q.OrderBy != "" { + sortRows(rows, q.OrderBy, q.Desc) + } + if q.Offset > 0 && uint(len(rows)) > q.Offset { + rows = rows[q.Offset:] + } else if q.Offset > 0 { + rows = nil + } + if q.Limit > 0 && uint(len(rows)) > q.Limit { + rows = rows[:q.Limit] + } + + results := reflect.MakeSlice(sliceVal.Type(), len(rows), len(rows)) + for i, row := range rows { + vp := reflect.New(structType) + MapToStruct(s, row, vp.Interface()) + if elemType.Kind() == reflect.Ptr { + results.Index(i).Set(vp) + } else { + results.Index(i).Set(vp.Elem()) + } + } + sliceVal.Set(results) + return nil +} + +func (m *memoryModel) Count(ctx context.Context, v interface{}, opts ...QueryOption) (int64, error) { + schema, err := m.schema(v) + if err != nil { + return 0, err + } + q := ApplyQueryOptions(opts...) + + m.mu.RLock() + defer m.mu.RUnlock() + + tbl := m.tables[schema.Table] + var count int64 + for _, row := range tbl { + if matchFilters(row, q.Filters) { + count++ + } + } + return count, nil +} + +func (m *memoryModel) Close() error { + return nil +} + +func (m *memoryModel) String() string { + return "memory" +} + +// matchFilters returns true if the row satisfies all filters. +func matchFilters(row map[string]any, filters []Filter) bool { + for _, f := range filters { + val, ok := row[f.Field] + if !ok { + return false + } + if !compareValues(val, f.Op, f.Value) { + return false + } + } + return true +} + +// compareValues compares two values with the given operator. +func compareValues(a any, op string, b any) bool { + switch op { + case "=": + return fmt.Sprint(a) == fmt.Sprint(b) + case "!=": + return fmt.Sprint(a) != fmt.Sprint(b) + case "LIKE": + pattern := fmt.Sprint(b) + val := fmt.Sprint(a) + if strings.HasPrefix(pattern, "%") && strings.HasSuffix(pattern, "%") { + return strings.Contains(val, pattern[1:len(pattern)-1]) + } + if strings.HasPrefix(pattern, "%") { + return strings.HasSuffix(val, pattern[1:]) + } + if strings.HasSuffix(pattern, "%") { + return strings.HasPrefix(val, pattern[:len(pattern)-1]) + } + return val == pattern + case "<", ">", "<=", ">=": + return compareNumeric(a, op, b) + default: + return false + } +} + +func compareNumeric(a any, op string, b any) bool { + af, aOk := toFloat64(a) + bf, bOk := toFloat64(b) + if !aOk || !bOk { + as, bs := fmt.Sprint(a), fmt.Sprint(b) + switch op { + case "<": + return as < bs + case ">": + return as > bs + case "<=": + return as <= bs + case ">=": + return as >= bs + } + return false + } + switch op { + case "<": + return af < bf + case ">": + return af > bf + case "<=": + return af <= bf + case ">=": + return af >= bf + } + return false +} + +func toFloat64(v any) (float64, bool) { + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return float64(rv.Int()), true + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return float64(rv.Uint()), true + case reflect.Float32, reflect.Float64: + return rv.Float(), true + default: + return 0, false + } +} + +func sortRows(rows []map[string]any, field string, desc bool) { + for i := 1; i < len(rows); i++ { + for j := i; j > 0; j-- { + a := fmt.Sprint(rows[j-1][field]) + b := fmt.Sprint(rows[j][field]) + shouldSwap := a > b + if desc { + shouldSwap = a < b + } + if shouldSwap { + rows[j-1], rows[j] = rows[j], rows[j-1] + } + } + } +} diff --git a/model/memory/memory.go b/model/memory/memory.go index 275113c69b..1da29d21f1 100644 --- a/model/memory/memory.go +++ b/model/memory/memory.go @@ -1,269 +1,12 @@ -// Package memory provides an in-memory Database implementation for the model package. -// Useful for testing and development. Data does not persist across restarts. +// Package memory provides an in-memory model.Model implementation. +// This is the same as model.NewModel() but importable as a standalone package. package memory import ( - "context" - "fmt" - "reflect" - "strings" - "sync" - "go-micro.dev/v5/model" ) -// Database is an in-memory model.Database implementation. -type Database struct { - mu sync.RWMutex - tables map[string]*table -} - -type table struct { - rows map[string]map[string]any -} - -// New creates a new in-memory database. -func New(opts ...model.Option) *Database { - return &Database{ - tables: make(map[string]*table), - } -} - -func (d *Database) Init(opts ...model.Option) error { - return nil -} - -func (d *Database) NewTable(schema *model.Schema) error { - d.mu.Lock() - defer d.mu.Unlock() - if _, ok := d.tables[schema.Table]; !ok { - d.tables[schema.Table] = &table{rows: make(map[string]map[string]any)} - } - return nil -} - -func (d *Database) Create(ctx context.Context, schema *model.Schema, key string, fields map[string]any) error { - d.mu.Lock() - defer d.mu.Unlock() - t := d.tables[schema.Table] - if _, exists := t.rows[key]; exists { - return model.ErrDuplicateKey - } - // Copy the map to avoid external mutation - row := make(map[string]any, len(fields)) - for k, v := range fields { - row[k] = v - } - t.rows[key] = row - return nil -} - -func (d *Database) Read(ctx context.Context, schema *model.Schema, key string) (map[string]any, error) { - d.mu.RLock() - defer d.mu.RUnlock() - t := d.tables[schema.Table] - row, ok := t.rows[key] - if !ok { - return nil, model.ErrNotFound - } - // Return a copy - result := make(map[string]any, len(row)) - for k, v := range row { - result[k] = v - } - return result, nil -} - -func (d *Database) Update(ctx context.Context, schema *model.Schema, key string, fields map[string]any) error { - d.mu.Lock() - defer d.mu.Unlock() - t := d.tables[schema.Table] - if _, ok := t.rows[key]; !ok { - return model.ErrNotFound - } - row := make(map[string]any, len(fields)) - for k, v := range fields { - row[k] = v - } - t.rows[key] = row - return nil -} - -func (d *Database) Delete(ctx context.Context, schema *model.Schema, key string) error { - d.mu.Lock() - defer d.mu.Unlock() - t := d.tables[schema.Table] - if _, ok := t.rows[key]; !ok { - return model.ErrNotFound - } - delete(t.rows, key) - return nil -} - -func (d *Database) List(ctx context.Context, schema *model.Schema, opts ...model.QueryOption) ([]map[string]any, error) { - q := model.ApplyQueryOptions(opts...) - - d.mu.RLock() - defer d.mu.RUnlock() - t := d.tables[schema.Table] - - var results []map[string]any - for _, row := range t.rows { - if matchFilters(row, q.Filters) { - // Copy row - cp := make(map[string]any, len(row)) - for k, v := range row { - cp[k] = v - } - results = append(results, cp) - } - } - - // Sort if OrderBy is set - if q.OrderBy != "" { - sortRows(results, q.OrderBy, q.Desc) - } - - // Apply offset - if q.Offset > 0 && uint(len(results)) > q.Offset { - results = results[q.Offset:] - } else if q.Offset > 0 { - results = nil - } - - // Apply limit - if q.Limit > 0 && uint(len(results)) > q.Limit { - results = results[:q.Limit] - } - - return results, nil -} - -func (d *Database) Count(ctx context.Context, schema *model.Schema, opts ...model.QueryOption) (int64, error) { - q := model.ApplyQueryOptions(opts...) - - d.mu.RLock() - defer d.mu.RUnlock() - t := d.tables[schema.Table] - - var count int64 - for _, row := range t.rows { - if matchFilters(row, q.Filters) { - count++ - } - } - return count, nil -} - -func (d *Database) Close() error { - return nil -} - -func (d *Database) String() string { - return "memory" -} - -// matchFilters returns true if the row satisfies all filters. -func matchFilters(row map[string]any, filters []model.Filter) bool { - for _, f := range filters { - val, ok := row[f.Field] - if !ok { - return false - } - if !compareValues(val, f.Op, f.Value) { - return false - } - } - return true -} - -// compareValues compares two values with the given operator. -func compareValues(a any, op string, b any) bool { - switch op { - case "=": - return fmt.Sprint(a) == fmt.Sprint(b) - case "!=": - return fmt.Sprint(a) != fmt.Sprint(b) - case "LIKE": - // Simple LIKE: supports % wildcard at start/end - pattern := fmt.Sprint(b) - val := fmt.Sprint(a) - if strings.HasPrefix(pattern, "%") && strings.HasSuffix(pattern, "%") { - return strings.Contains(val, pattern[1:len(pattern)-1]) - } - if strings.HasPrefix(pattern, "%") { - return strings.HasSuffix(val, pattern[1:]) - } - if strings.HasSuffix(pattern, "%") { - return strings.HasPrefix(val, pattern[:len(pattern)-1]) - } - return val == pattern - case "<", ">", "<=", ">=": - return compareNumeric(a, op, b) - default: - return false - } -} - -// compareNumeric attempts numeric comparison. -func compareNumeric(a any, op string, b any) bool { - af, aOk := toFloat64(a) - bf, bOk := toFloat64(b) - if !aOk || !bOk { - // Fall back to string comparison - as, bs := fmt.Sprint(a), fmt.Sprint(b) - switch op { - case "<": - return as < bs - case ">": - return as > bs - case "<=": - return as <= bs - case ">=": - return as >= bs - } - return false - } - switch op { - case "<": - return af < bf - case ">": - return af > bf - case "<=": - return af <= bf - case ">=": - return af >= bf - } - return false -} - -func toFloat64(v any) (float64, bool) { - rv := reflect.ValueOf(v) - switch rv.Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - return float64(rv.Int()), true - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return float64(rv.Uint()), true - case reflect.Float32, reflect.Float64: - return rv.Float(), true - default: - return 0, false - } -} - -// sortRows sorts rows by a field. Simple insertion sort for small datasets. -func sortRows(rows []map[string]any, field string, desc bool) { - for i := 1; i < len(rows); i++ { - for j := i; j > 0; j-- { - a := fmt.Sprint(rows[j-1][field]) - b := fmt.Sprint(rows[j][field]) - shouldSwap := a > b - if desc { - shouldSwap = a < b - } - if shouldSwap { - rows[j-1], rows[j] = rows[j], rows[j-1] - } - } - } +// New creates a new in-memory model. +func New(opts ...model.Option) model.Model { + return model.NewModel(opts...) } diff --git a/model/memory/memory_test.go b/model/memory/memory_test.go index 2a0da5ebc1..2b75bcf80c 100644 --- a/model/memory/memory_test.go +++ b/model/memory/memory_test.go @@ -14,24 +14,28 @@ type User struct { Age int `json:"age"` } -func setup(t *testing.T) *model.Model[User] { +func setup(t *testing.T) model.Model { t.Helper() db := New() - return model.New[User](db) + if err := db.Register(&User{}); err != nil { + t.Fatalf("register: %v", err) + } + return db } func TestCRUD(t *testing.T) { - users := setup(t) + db := setup(t) ctx := context.Background() // Create - err := users.Create(ctx, &User{ID: "1", Name: "Alice", Email: "alice@test.com", Age: 30}) + err := db.Create(ctx, &User{ID: "1", Name: "Alice", Email: "alice@test.com", Age: 30}) if err != nil { t.Fatalf("create: %v", err) } // Read - u, err := users.Read(ctx, "1") + u := &User{} + err = db.Read(ctx, "1", u) if err != nil { t.Fatalf("read: %v", err) } @@ -45,12 +49,13 @@ func TestCRUD(t *testing.T) { // Update u.Name = "Alice Updated" u.Age = 31 - err = users.Update(ctx, u) + err = db.Update(ctx, u) if err != nil { t.Fatalf("update: %v", err) } - u2, _ := users.Read(ctx, "1") + u2 := &User{} + db.Read(ctx, "1", u2) if u2.Name != "Alice Updated" { t.Errorf("expected 'Alice Updated', got %s", u2.Name) } @@ -59,58 +64,58 @@ func TestCRUD(t *testing.T) { } // Delete - err = users.Delete(ctx, "1") + err = db.Delete(ctx, "1", &User{}) if err != nil { t.Fatalf("delete: %v", err) } - _, err = users.Read(ctx, "1") + err = db.Read(ctx, "1", &User{}) if err != model.ErrNotFound { t.Errorf("expected ErrNotFound, got %v", err) } } func TestDuplicateKey(t *testing.T) { - users := setup(t) + db := setup(t) ctx := context.Background() - users.Create(ctx, &User{ID: "1", Name: "Alice"}) - err := users.Create(ctx, &User{ID: "1", Name: "Bob"}) + db.Create(ctx, &User{ID: "1", Name: "Alice"}) + err := db.Create(ctx, &User{ID: "1", Name: "Bob"}) if err != model.ErrDuplicateKey { t.Errorf("expected ErrDuplicateKey, got %v", err) } } func TestNotFound(t *testing.T) { - users := setup(t) + db := setup(t) ctx := context.Background() - _, err := users.Read(ctx, "nonexistent") + err := db.Read(ctx, "nonexistent", &User{}) if err != model.ErrNotFound { t.Errorf("expected ErrNotFound, got %v", err) } - err = users.Update(ctx, &User{ID: "nonexistent"}) + err = db.Update(ctx, &User{ID: "nonexistent"}) if err != model.ErrNotFound { t.Errorf("expected ErrNotFound on update, got %v", err) } - err = users.Delete(ctx, "nonexistent") + err = db.Delete(ctx, "nonexistent", &User{}) if err != model.ErrNotFound { t.Errorf("expected ErrNotFound on delete, got %v", err) } } func TestList(t *testing.T) { - users := setup(t) + db := setup(t) ctx := context.Background() - users.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30}) - users.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25}) - users.Create(ctx, &User{ID: "3", Name: "Charlie", Age: 35}) + db.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30}) + db.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25}) + db.Create(ctx, &User{ID: "3", Name: "Charlie", Age: 35}) - // List all - all, err := users.List(ctx) + var all []*User + err := db.List(ctx, &all) if err != nil { t.Fatalf("list: %v", err) } @@ -120,15 +125,15 @@ func TestList(t *testing.T) { } func TestListWithFilter(t *testing.T) { - users := setup(t) + db := setup(t) ctx := context.Background() - users.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30}) - users.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25}) - users.Create(ctx, &User{ID: "3", Name: "Alice", Age: 35}) + db.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30}) + db.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25}) + db.Create(ctx, &User{ID: "3", Name: "Alice", Age: 35}) - // Filter by name - results, err := users.List(ctx, model.Where("name", "Alice")) + var results []*User + err := db.List(ctx, &results, model.Where("name", "Alice")) if err != nil { t.Fatalf("list with filter: %v", err) } @@ -138,16 +143,16 @@ func TestListWithFilter(t *testing.T) { } func TestListWithLimitOffset(t *testing.T) { - users := setup(t) + db := setup(t) ctx := context.Background() - users.Create(ctx, &User{ID: "1", Name: "A", Age: 1}) - users.Create(ctx, &User{ID: "2", Name: "B", Age: 2}) - users.Create(ctx, &User{ID: "3", Name: "C", Age: 3}) - users.Create(ctx, &User{ID: "4", Name: "D", Age: 4}) + db.Create(ctx, &User{ID: "1", Name: "A", Age: 1}) + db.Create(ctx, &User{ID: "2", Name: "B", Age: 2}) + db.Create(ctx, &User{ID: "3", Name: "C", Age: 3}) + db.Create(ctx, &User{ID: "4", Name: "D", Age: 4}) - // Sort by name, get 2 starting from offset 1 - results, err := users.List(ctx, + var results []*User + err := db.List(ctx, &results, model.OrderAsc("name"), model.Limit(2), model.Offset(1), @@ -167,14 +172,14 @@ func TestListWithLimitOffset(t *testing.T) { } func TestCount(t *testing.T) { - users := setup(t) + db := setup(t) ctx := context.Background() - users.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30}) - users.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25}) - users.Create(ctx, &User{ID: "3", Name: "Alice", Age: 35}) + db.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30}) + db.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25}) + db.Create(ctx, &User{ID: "3", Name: "Alice", Age: 35}) - count, err := users.Count(ctx) + count, err := db.Count(ctx, &User{}) if err != nil { t.Fatalf("count: %v", err) } @@ -182,7 +187,7 @@ func TestCount(t *testing.T) { t.Errorf("expected 3, got %d", count) } - count, err = users.Count(ctx, model.Where("name", "Alice")) + count, err = db.Count(ctx, &User{}, model.Where("name", "Alice")) if err != nil { t.Fatalf("count with filter: %v", err) } @@ -192,15 +197,15 @@ func TestCount(t *testing.T) { } func TestWhereOp(t *testing.T) { - users := setup(t) + db := setup(t) ctx := context.Background() - users.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30}) - users.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25}) - users.Create(ctx, &User{ID: "3", Name: "Charlie", Age: 35}) + db.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30}) + db.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25}) + db.Create(ctx, &User{ID: "3", Name: "Charlie", Age: 35}) - // Age > 28 - results, err := users.List(ctx, model.WhereOp("age", ">", 28)) + var results []*User + err := db.List(ctx, &results, model.WhereOp("age", ">", 28)) if err != nil { t.Fatalf("list: %v", err) } diff --git a/model/model.go b/model/model.go index 475612004b..c1fb9e4ea1 100644 --- a/model/model.go +++ b/model/model.go @@ -1,13 +1,9 @@ -// Package model provides a typed data model layer with CRUD operations and query support. -// It uses Go generics for type-safe access and supports multiple backends (memory, SQLite, Postgres). +// Package model is an interface for structured data storage with schema awareness. package model import ( "context" "errors" - "fmt" - "reflect" - "strings" ) var ( @@ -15,237 +11,76 @@ var ( ErrNotFound = errors.New("not found") // ErrDuplicateKey is returned when a record with the same key already exists. ErrDuplicateKey = errors.New("duplicate key") + // ErrNotRegistered is returned when a table has not been registered. + ErrNotRegistered = errors.New("table not registered") + // DefaultModel is the default model. + DefaultModel Model = NewModel() ) -// Database is the backend interface that model implementations must satisfy. -// Each backend (memory, sqlite, postgres) implements this interface. -type Database interface { - // Init initializes the database connection. +// Model is a structured data storage interface. +type Model interface { + // Init initializes the model. Init(...Option) error - // NewTable ensures the table exists for the given schema. - NewTable(schema *Schema) error + // Register registers a struct type as a table. + Register(v interface{}, opts ...RegisterOption) error // Create inserts a new record. Returns ErrDuplicateKey if key exists. - Create(ctx context.Context, schema *Schema, key string, fields map[string]any) error - // Read returns a single record by key. Returns ErrNotFound if missing. - Read(ctx context.Context, schema *Schema, key string) (map[string]any, error) - // Update modifies an existing record by key. Returns ErrNotFound if missing. - Update(ctx context.Context, schema *Schema, key string, fields map[string]any) error - // Delete removes a record by key. Returns ErrNotFound if missing. - Delete(ctx context.Context, schema *Schema, key string) error - // List returns all records matching the query options. - List(ctx context.Context, schema *Schema, opts ...QueryOption) ([]map[string]any, error) - // Count returns the number of records matching the query options. - Count(ctx context.Context, schema *Schema, opts ...QueryOption) (int64, error) - // Close closes the database connection. + Create(ctx context.Context, v interface{}) error + // Read retrieves a record by key into v. Returns ErrNotFound if missing. + Read(ctx context.Context, key string, v interface{}) error + // Update modifies an existing record. Returns ErrNotFound if missing. + Update(ctx context.Context, v interface{}) error + // Delete removes a record by key. v is a pointer to the struct type. + Delete(ctx context.Context, key string, v interface{}) error + // List retrieves records matching the query. result must be a pointer to a slice of struct pointers. + List(ctx context.Context, result interface{}, opts ...QueryOption) error + // Count returns the number of matching records. v is a pointer to the struct type. + Count(ctx context.Context, v interface{}, opts ...QueryOption) (int64, error) + // Close closes the model. Close() error - // String returns the implementation name. + // String returns the name of the implementation. String() string } -// Schema describes a model's storage layout, derived from struct tags. -type Schema struct { - // Table name in the database. - Table string - // Key is the name of the primary key field. - Key string - // Fields maps Go field names to their column metadata. - Fields []Field -} - -// Field describes a single field in the schema. -type Field struct { - // Name is the Go struct field name. - Name string - // Column is the database column name (from json tag or lowercased name). - Column string - // Type is the Go reflect type. - Type reflect.Type - // IsKey indicates this is the primary key field. - IsKey bool - // Index indicates this field should be indexed. - Index bool -} - -// Model provides typed CRUD operations for a specific Go struct type. -type Model[T any] struct { - db Database - schema *Schema -} - -// New creates a new Model for the given type T, backed by the provided database. -// T must be a struct with at least one field tagged `model:"key"`. -func New[T any](db Database, opts ...ModelOption) *Model[T] { - var t T - schema := buildSchema(reflect.TypeOf(t)) +type Option func(*Options) - // Apply model options - for _, o := range opts { - o(schema) - } - - // Ensure table exists - if err := db.NewTable(schema); err != nil { - panic(fmt.Sprintf("model: failed to create table %q: %v", schema.Table, err)) - } - - return &Model[T]{ - db: db, - schema: schema, - } -} - -// Create inserts a new record. -func (m *Model[T]) Create(ctx context.Context, v *T) error { - fields := structToMap(m.schema, v) - key, ok := fields[m.schema.Key] - if !ok { - return fmt.Errorf("model: key field %q not set", m.schema.Key) - } - return m.db.Create(ctx, m.schema, fmt.Sprint(key), fields) -} +type RegisterOption func(*Schema) -// Read retrieves a record by its primary key. -func (m *Model[T]) Read(ctx context.Context, key string) (*T, error) { - fields, err := m.db.Read(ctx, m.schema, key) - if err != nil { - return nil, err - } - v := mapToStruct[T](m.schema, fields) - return v, nil +// NewModel returns the default in-memory model. +func NewModel(opts ...Option) Model { + return newMemoryModel(opts...) } -// Update modifies an existing record. -func (m *Model[T]) Update(ctx context.Context, v *T) error { - fields := structToMap(m.schema, v) - key, ok := fields[m.schema.Key] - if !ok { - return fmt.Errorf("model: key field %q not set", m.schema.Key) - } - return m.db.Update(ctx, m.schema, fmt.Sprint(key), fields) +// Register registers a struct type with the default model. +func Register(v interface{}, opts ...RegisterOption) error { + return DefaultModel.Register(v, opts...) } -// Delete removes a record by its primary key. -func (m *Model[T]) Delete(ctx context.Context, key string) error { - return m.db.Delete(ctx, m.schema, key) +// Create inserts a new record using the default model. +func Create(ctx context.Context, v interface{}) error { + return DefaultModel.Create(ctx, v) } -// List returns records matching the query options. -func (m *Model[T]) List(ctx context.Context, opts ...QueryOption) ([]*T, error) { - rows, err := m.db.List(ctx, m.schema, opts...) - if err != nil { - return nil, err - } - results := make([]*T, len(rows)) - for i, row := range rows { - results[i] = mapToStruct[T](m.schema, row) - } - return results, nil +// Read retrieves a record by key using the default model. +func Read(ctx context.Context, key string, v interface{}) error { + return DefaultModel.Read(ctx, key, v) } -// Count returns the number of records matching the query options. -func (m *Model[T]) Count(ctx context.Context, opts ...QueryOption) (int64, error) { - return m.db.Count(ctx, m.schema, opts...) +// Update modifies an existing record using the default model. +func Update(ctx context.Context, v interface{}) error { + return DefaultModel.Update(ctx, v) } -// Schema returns the model's schema (useful for debugging/introspection). -func (m *Model[T]) Schema() *Schema { - return m.schema -} - -// buildSchema extracts the Schema from a struct type using reflection. -func buildSchema(t reflect.Type) *Schema { - if t.Kind() == reflect.Ptr { - t = t.Elem() - } - - schema := &Schema{ - Table: strings.ToLower(t.Name()) + "s", - } - - for i := 0; i < t.NumField(); i++ { - f := t.Field(i) - if !f.IsExported() { - continue - } - - field := Field{ - Name: f.Name, - Type: f.Type, - } - - // Column name: use json tag if present, else lowercase field name - if tag := f.Tag.Get("json"); tag != "" { - parts := strings.Split(tag, ",") - if parts[0] != "" && parts[0] != "-" { - field.Column = parts[0] - } - } - if field.Column == "" { - field.Column = strings.ToLower(f.Name) - } - - // Check model tag - if tag := f.Tag.Get("model"); tag != "" { - for _, opt := range strings.Split(tag, ",") { - switch opt { - case "key": - field.IsKey = true - schema.Key = field.Column - case "index": - field.Index = true - } - } - } - - schema.Fields = append(schema.Fields, field) - } - - if schema.Key == "" { - // Default to "id" if no key tag found - for i := range schema.Fields { - if schema.Fields[i].Column == "id" { - schema.Fields[i].IsKey = true - schema.Key = "id" - break - } - } - } - - return schema +// Delete removes a record by key using the default model. +func Delete(ctx context.Context, key string, v interface{}) error { + return DefaultModel.Delete(ctx, key, v) } -// structToMap converts a struct to a map of column name → value. -func structToMap[T any](schema *Schema, v *T) map[string]any { - rv := reflect.ValueOf(v).Elem() - fields := make(map[string]any, len(schema.Fields)) - for _, f := range schema.Fields { - fv := rv.FieldByName(f.Name) - if fv.IsValid() { - fields[f.Column] = fv.Interface() - } - } - return fields +// List retrieves records matching the query using the default model. +func List(ctx context.Context, result interface{}, opts ...QueryOption) error { + return DefaultModel.List(ctx, result, opts...) } -// mapToStruct converts a map of column name → value back to a struct. -func mapToStruct[T any](schema *Schema, fields map[string]any) *T { - v := new(T) - rv := reflect.ValueOf(v).Elem() - for _, f := range schema.Fields { - val, ok := fields[f.Column] - if !ok { - continue - } - fv := rv.FieldByName(f.Name) - if !fv.IsValid() || !fv.CanSet() { - continue - } - rval := reflect.ValueOf(val) - if rval.Type().AssignableTo(fv.Type()) { - fv.Set(rval) - } else if rval.Type().ConvertibleTo(fv.Type()) { - fv.Set(rval.Convert(fv.Type())) - } - } - return v +// Count returns the number of matching records using the default model. +func Count(ctx context.Context, v interface{}, opts ...QueryOption) (int64, error) { + return DefaultModel.Count(ctx, v, opts...) } diff --git a/model/model_test.go b/model/model_test.go index ab7c7fd802..4ee55ed864 100644 --- a/model/model_test.go +++ b/model/model_test.go @@ -1,7 +1,6 @@ package model import ( - "reflect" "testing" ) @@ -13,7 +12,7 @@ type TestUser struct { } func TestBuildSchema(t *testing.T) { - schema := buildSchema(reflect.TypeOf(TestUser{})) + schema := BuildSchema(TestUser{}) if schema.Table != "testusers" { t.Errorf("expected table 'testusers', got %q", schema.Table) @@ -25,7 +24,6 @@ func TestBuildSchema(t *testing.T) { t.Fatalf("expected 4 fields, got %d", len(schema.Fields)) } - // Check key field var keyField Field var indexField Field for _, f := range schema.Fields { @@ -50,15 +48,14 @@ func TestBuildSchema_DefaultKey(t *testing.T) { Name string `json:"name"` } - schema := buildSchema(reflect.TypeOf(Item{})) + schema := BuildSchema(Item{}) if schema.Key != "id" { t.Errorf("expected default key 'id', got %q", schema.Key) } } func TestBuildSchema_WithTable(t *testing.T) { - schema := buildSchema(reflect.TypeOf(TestUser{})) - WithTable("my_users")(schema) + schema := BuildSchema(TestUser{}, WithTable("my_users")) if schema.Table != "my_users" { t.Errorf("expected table 'my_users', got %q", schema.Table) @@ -66,10 +63,10 @@ func TestBuildSchema_WithTable(t *testing.T) { } func TestStructToMap(t *testing.T) { - schema := buildSchema(reflect.TypeOf(TestUser{})) + schema := BuildSchema(TestUser{}) u := &TestUser{ID: "1", Name: "Alice", Email: "alice@example.com", Age: 30} - m := structToMap(schema, u) + m := StructToMap(schema, u) if m["id"] != "1" { t.Errorf("expected id '1', got %v", m["id"]) @@ -86,7 +83,7 @@ func TestStructToMap(t *testing.T) { } func TestMapToStruct(t *testing.T) { - schema := buildSchema(reflect.TypeOf(TestUser{})) + schema := BuildSchema(TestUser{}) m := map[string]any{ "id": "1", "name": "Bob", @@ -94,7 +91,8 @@ func TestMapToStruct(t *testing.T) { "age": 25, } - u := mapToStruct[TestUser](schema, m) + u := &TestUser{} + MapToStruct(schema, m, u) if u.ID != "1" { t.Errorf("expected ID '1', got %q", u.ID) diff --git a/model/options.go b/model/options.go index c6670fef77..43a36ed5a1 100644 --- a/model/options.go +++ b/model/options.go @@ -1,35 +1,29 @@ package model -// Option configures a Database. -type Option func(*DatabaseOptions) - -// DatabaseOptions holds configuration for a Database backend. -type DatabaseOptions struct { +// Options holds configuration for a Model. +type Options struct { // DSN is the data source name / connection string. DSN string } -// WithDSN sets the data source name for the database connection. +// WithDSN sets the data source name. func WithDSN(dsn string) Option { - return func(o *DatabaseOptions) { + return func(o *Options) { o.DSN = dsn } } -// NewDatabaseOptions creates DatabaseOptions with defaults applied. -func NewDatabaseOptions(opts ...Option) DatabaseOptions { - o := DatabaseOptions{} +// NewOptions creates Options with defaults applied. +func NewOptions(opts ...Option) Options { + o := Options{} for _, opt := range opts { opt(&o) } return o } -// ModelOption configures a Model instance. -type ModelOption func(*Schema) - // WithTable overrides the auto-derived table name. -func WithTable(name string) ModelOption { +func WithTable(name string) RegisterOption { return func(s *Schema) { s.Table = name } diff --git a/model/postgres/postgres.go b/model/postgres/postgres.go index d78f15efef..cad1b9c39e 100644 --- a/model/postgres/postgres.go +++ b/model/postgres/postgres.go @@ -1,4 +1,4 @@ -// Package postgres provides a PostgreSQL Database implementation for the model package. +// Package postgres provides a PostgreSQL model.Model implementation. // Uses lib/pq driver. Best for production deployments with rich query support. package postgres @@ -8,32 +8,47 @@ import ( "fmt" "reflect" "strings" + "sync" _ "github.com/lib/pq" "go-micro.dev/v5/model" ) -// Database is a PostgreSQL model.Database implementation. -type Database struct { - db *sql.DB +type postgresModel struct { + db *sql.DB + mu sync.RWMutex + schemas map[string]*model.Schema + types map[reflect.Type]*model.Schema } -// New creates a new Postgres database. DSN is a connection string +// New creates a new Postgres model. DSN is a connection string // (e.g., "postgres://user:pass@localhost/dbname?sslmode=disable"). -func New(dsn string) *Database { +func New(dsn string) model.Model { db, err := sql.Open("postgres", dsn) if err != nil { panic(fmt.Sprintf("model/postgres: failed to open: %v", err)) } - return &Database{db: db} + return &postgresModel{ + db: db, + schemas: make(map[string]*model.Schema), + types: make(map[reflect.Type]*model.Schema), + } } -func (d *Database) Init(opts ...model.Option) error { +func (d *postgresModel) Init(opts ...model.Option) error { return d.db.Ping() } -func (d *Database) NewTable(schema *model.Schema) error { +func (d *postgresModel) Register(v interface{}, opts ...model.RegisterOption) error { + schema := model.BuildSchema(v, opts...) + t := model.ResolveType(v) + + d.mu.Lock() + d.schemas[schema.Table] = schema + d.types[t] = schema + d.mu.Unlock() + var cols []string for _, f := range schema.Fields { colType := goTypeToPostgres(f.Type) @@ -49,7 +64,6 @@ func (d *Database) NewTable(schema *model.Schema) error { return fmt.Errorf("model/postgres: create table: %w", err) } - // Create indexes for _, f := range schema.Fields { if f.Index && !f.IsKey { idx := fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s ON %s (%s)", @@ -65,10 +79,26 @@ func (d *Database) NewTable(schema *model.Schema) error { return nil } -func (d *Database) Create(ctx context.Context, schema *model.Schema, key string, fields map[string]any) error { +func (d *postgresModel) schema(v interface{}) (*model.Schema, error) { + t := model.ResolveType(v) + d.mu.RLock() + s, ok := d.types[t] + d.mu.RUnlock() + if !ok { + return nil, model.ErrNotRegistered + } + return s, nil +} + +func (d *postgresModel) Create(ctx context.Context, v interface{}) error { + schema, err := d.schema(v) + if err != nil { + return err + } + fields := model.StructToMap(schema, v) cols, placeholders, values := buildInsert(schema, fields) query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", quoteIdent(schema.Table), cols, placeholders) - _, err := d.db.ExecContext(ctx, query, values...) + _, err = d.db.ExecContext(ctx, query, values...) if err != nil { if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique constraint") { return model.ErrDuplicateKey @@ -78,14 +108,29 @@ func (d *Database) Create(ctx context.Context, schema *model.Schema, key string, return nil } -func (d *Database) Read(ctx context.Context, schema *model.Schema, key string) (map[string]any, error) { +func (d *postgresModel) Read(ctx context.Context, key string, v interface{}) error { + schema, err := d.schema(v) + if err != nil { + return err + } cols := columnList(schema) query := fmt.Sprintf("SELECT %s FROM %s WHERE %s = $1", cols, quoteIdent(schema.Table), quoteIdent(schema.Key)) row := d.db.QueryRowContext(ctx, query, key) - return scanRow(schema, row) + fields, err := scanRow(schema, row) + if err != nil { + return err + } + model.MapToStruct(schema, fields, v) + return nil } -func (d *Database) Update(ctx context.Context, schema *model.Schema, key string, fields map[string]any) error { +func (d *postgresModel) Update(ctx context.Context, v interface{}) error { + schema, err := d.schema(v) + if err != nil { + return err + } + fields := model.StructToMap(schema, v) + key := model.KeyValue(schema, v) setClauses, values := buildUpdate(schema, fields) values = append(values, key) paramIdx := len(values) @@ -102,7 +147,11 @@ func (d *Database) Update(ctx context.Context, schema *model.Schema, key string, return nil } -func (d *Database) Delete(ctx context.Context, schema *model.Schema, key string) error { +func (d *postgresModel) Delete(ctx context.Context, key string, v interface{}) error { + schema, err := d.schema(v) + if err != nil { + return err + } query := fmt.Sprintf("DELETE FROM %s WHERE %s = $1", quoteIdent(schema.Table), quoteIdent(schema.Key)) result, err := d.db.ExecContext(ctx, query, key) if err != nil { @@ -115,7 +164,25 @@ func (d *Database) Delete(ctx context.Context, schema *model.Schema, key string) return nil } -func (d *Database) List(ctx context.Context, schema *model.Schema, opts ...model.QueryOption) ([]map[string]any, error) { +func (d *postgresModel) List(ctx context.Context, result interface{}, opts ...model.QueryOption) error { + rv := reflect.ValueOf(result) + if rv.Kind() != reflect.Ptr || rv.Elem().Kind() != reflect.Slice { + return fmt.Errorf("model/postgres: result must be a pointer to a slice") + } + sliceVal := rv.Elem() + elemType := sliceVal.Type().Elem() + structType := elemType + if structType.Kind() == reflect.Ptr { + structType = structType.Elem() + } + + d.mu.RLock() + schema, ok := d.types[structType] + d.mu.RUnlock() + if !ok { + return model.ErrNotRegistered + } + q := model.ApplyQueryOptions(opts...) cols := columnList(schema) @@ -147,14 +214,34 @@ func (d *Database) List(ctx context.Context, schema *model.Schema, opts ...model rows, err := d.db.QueryContext(ctx, query, args...) if err != nil { - return nil, fmt.Errorf("model/postgres: list: %w", err) + return fmt.Errorf("model/postgres: list: %w", err) } defer rows.Close() - return scanRows(schema, rows) + fieldMaps, err := scanRows(schema, rows) + if err != nil { + return err + } + + results := reflect.MakeSlice(sliceVal.Type(), len(fieldMaps), len(fieldMaps)) + for i, fields := range fieldMaps { + vp := reflect.New(structType) + model.MapToStruct(schema, fields, vp.Interface()) + if elemType.Kind() == reflect.Ptr { + results.Index(i).Set(vp) + } else { + results.Index(i).Set(vp.Elem()) + } + } + sliceVal.Set(results) + return nil } -func (d *Database) Count(ctx context.Context, schema *model.Schema, opts ...model.QueryOption) (int64, error) { +func (d *postgresModel) Count(ctx context.Context, v interface{}, opts ...model.QueryOption) (int64, error) { + schema, err := d.schema(v) + if err != nil { + return 0, err + } q := model.ApplyQueryOptions(opts...) query := fmt.Sprintf("SELECT COUNT(*) FROM %s", quoteIdent(schema.Table)) @@ -168,18 +255,18 @@ func (d *Database) Count(ctx context.Context, schema *model.Schema, opts ...mode } var count int64 - err := d.db.QueryRowContext(ctx, query, args...).Scan(&count) + err = d.db.QueryRowContext(ctx, query, args...).Scan(&count) if err != nil { return 0, fmt.Errorf("model/postgres: count: %w", err) } return count, nil } -func (d *Database) Close() error { +func (d *postgresModel) Close() error { return d.db.Close() } -func (d *Database) String() string { +func (d *postgresModel) String() string { return "postgres" } diff --git a/model/schema.go b/model/schema.go new file mode 100644 index 0000000000..11e66d64b1 --- /dev/null +++ b/model/schema.go @@ -0,0 +1,162 @@ +package model + +import ( + "fmt" + "reflect" + "strings" +) + +// Schema describes a model's storage layout, derived from struct tags. +type Schema struct { + // Table name in the database. + Table string + // Key is the name of the primary key field. + Key string + // Fields maps Go field names to their column metadata. + Fields []Field +} + +// Field describes a single field in the schema. +type Field struct { + // Name is the Go struct field name. + Name string + // Column is the database column name (from json tag or lowercased name). + Column string + // Type is the Go reflect type. + Type reflect.Type + // IsKey indicates this is the primary key field. + IsKey bool + // Index indicates this field should be indexed. + Index bool +} + +// BuildSchema extracts a Schema from a struct type using reflection. +func BuildSchema(v interface{}, opts ...RegisterOption) *Schema { + t := reflect.TypeOf(v) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + + schema := &Schema{ + Table: strings.ToLower(t.Name()) + "s", + } + + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if !f.IsExported() { + continue + } + + field := Field{ + Name: f.Name, + Type: f.Type, + } + + // Column name: use json tag if present, else lowercase field name + if tag := f.Tag.Get("json"); tag != "" { + parts := strings.Split(tag, ",") + if parts[0] != "" && parts[0] != "-" { + field.Column = parts[0] + } + } + if field.Column == "" { + field.Column = strings.ToLower(f.Name) + } + + // Check model tag + if tag := f.Tag.Get("model"); tag != "" { + for _, opt := range strings.Split(tag, ",") { + switch opt { + case "key": + field.IsKey = true + schema.Key = field.Column + case "index": + field.Index = true + } + } + } + + schema.Fields = append(schema.Fields, field) + } + + if schema.Key == "" { + // Default to "id" if no key tag found + for i := range schema.Fields { + if schema.Fields[i].Column == "id" { + schema.Fields[i].IsKey = true + schema.Key = "id" + break + } + } + } + + for _, o := range opts { + o(schema) + } + + return schema +} + +// StructToMap converts a struct pointer to a map of column name → value. +func StructToMap(schema *Schema, v interface{}) map[string]any { + rv := reflect.ValueOf(v) + if rv.Kind() == reflect.Ptr { + rv = rv.Elem() + } + fields := make(map[string]any, len(schema.Fields)) + for _, f := range schema.Fields { + fv := rv.FieldByName(f.Name) + if fv.IsValid() { + fields[f.Column] = fv.Interface() + } + } + return fields +} + +// MapToStruct fills a struct pointer from a map of column name → value. +func MapToStruct(schema *Schema, fields map[string]any, v interface{}) { + rv := reflect.ValueOf(v) + if rv.Kind() == reflect.Ptr { + rv = rv.Elem() + } + for _, f := range schema.Fields { + val, ok := fields[f.Column] + if !ok { + continue + } + fv := rv.FieldByName(f.Name) + if !fv.IsValid() || !fv.CanSet() { + continue + } + rval := reflect.ValueOf(val) + if rval.Type().AssignableTo(fv.Type()) { + fv.Set(rval) + } else if rval.Type().ConvertibleTo(fv.Type()) { + fv.Set(rval.Convert(fv.Type())) + } + } +} + +// NewFromSchema creates a new zero-value struct pointer for the given schema's original type. +func NewFromSchema(schema *Schema, rtype reflect.Type) interface{} { + return reflect.New(rtype).Interface() +} + +// KeyValue extracts the key value from a struct using the schema. +func KeyValue(schema *Schema, v interface{}) string { + fields := StructToMap(schema, v) + key, ok := fields[schema.Key] + if !ok { + return "" + } + return fmt.Sprint(key) +} + +// ResolveType returns the struct reflect.Type from a value (handles pointers and slices). +func ResolveType(v interface{}) reflect.Type { + t := reflect.TypeOf(v) + for t.Kind() == reflect.Ptr || t.Kind() == reflect.Slice { + t = t.Elem() + } + return t +} diff --git a/model/sqlite/sqlite.go b/model/sqlite/sqlite.go index a120e1b99f..e46ce54d48 100644 --- a/model/sqlite/sqlite.go +++ b/model/sqlite/sqlite.go @@ -1,4 +1,4 @@ -// Package sqlite provides a SQLite Database implementation for the model package. +// Package sqlite provides a SQLite model.Model implementation. // Uses mattn/go-sqlite3 for broad compatibility. // Good for development, testing, and single-node production. package sqlite @@ -9,19 +9,22 @@ import ( "fmt" "reflect" "strings" + "sync" _ "github.com/mattn/go-sqlite3" "go-micro.dev/v5/model" ) -// Database is a SQLite model.Database implementation. -type Database struct { - db *sql.DB +type sqliteModel struct { + db *sql.DB + mu sync.RWMutex + schemas map[string]*model.Schema + types map[reflect.Type]*model.Schema } -// New creates a new SQLite database. DSN is the file path (e.g., "data.db" or ":memory:"). -func New(dsn string) *Database { +// New creates a new SQLite model. DSN is the file path (e.g., "data.db" or ":memory:"). +func New(dsn string) model.Model { if dsn == "" { dsn = ":memory:" } @@ -29,16 +32,27 @@ func New(dsn string) *Database { if err != nil { panic(fmt.Sprintf("model/sqlite: failed to open %q: %v", dsn, err)) } - // Enable WAL mode for better concurrent read performance db.Exec("PRAGMA journal_mode=WAL") - return &Database{db: db} + return &sqliteModel{ + db: db, + schemas: make(map[string]*model.Schema), + types: make(map[reflect.Type]*model.Schema), + } } -func (d *Database) Init(opts ...model.Option) error { +func (d *sqliteModel) Init(opts ...model.Option) error { return d.db.Ping() } -func (d *Database) NewTable(schema *model.Schema) error { +func (d *sqliteModel) Register(v interface{}, opts ...model.RegisterOption) error { + schema := model.BuildSchema(v, opts...) + t := model.ResolveType(v) + + d.mu.Lock() + d.schemas[schema.Table] = schema + d.types[t] = schema + d.mu.Unlock() + var cols []string for _, f := range schema.Fields { colType := goTypeToSQLite(f.Type) @@ -54,7 +68,6 @@ func (d *Database) NewTable(schema *model.Schema) error { return fmt.Errorf("model/sqlite: create table: %w", err) } - // Create indexes for _, f := range schema.Fields { if f.Index && !f.IsKey { idx := fmt.Sprintf("CREATE INDEX IF NOT EXISTS %q ON %q (%q)", @@ -68,10 +81,26 @@ func (d *Database) NewTable(schema *model.Schema) error { return nil } -func (d *Database) Create(ctx context.Context, schema *model.Schema, key string, fields map[string]any) error { +func (d *sqliteModel) schema(v interface{}) (*model.Schema, error) { + t := model.ResolveType(v) + d.mu.RLock() + s, ok := d.types[t] + d.mu.RUnlock() + if !ok { + return nil, model.ErrNotRegistered + } + return s, nil +} + +func (d *sqliteModel) Create(ctx context.Context, v interface{}) error { + schema, err := d.schema(v) + if err != nil { + return err + } + fields := model.StructToMap(schema, v) cols, placeholders, values := buildInsert(schema, fields) query := fmt.Sprintf("INSERT INTO %q (%s) VALUES (%s)", schema.Table, cols, placeholders) - _, err := d.db.ExecContext(ctx, query, values...) + _, err = d.db.ExecContext(ctx, query, values...) if err != nil { if strings.Contains(err.Error(), "UNIQUE constraint") || strings.Contains(err.Error(), "PRIMARY KEY") { return model.ErrDuplicateKey @@ -81,14 +110,29 @@ func (d *Database) Create(ctx context.Context, schema *model.Schema, key string, return nil } -func (d *Database) Read(ctx context.Context, schema *model.Schema, key string) (map[string]any, error) { +func (d *sqliteModel) Read(ctx context.Context, key string, v interface{}) error { + schema, err := d.schema(v) + if err != nil { + return err + } cols := columnList(schema) query := fmt.Sprintf("SELECT %s FROM %q WHERE %q = ?", cols, schema.Table, schema.Key) row := d.db.QueryRowContext(ctx, query, key) - return scanRow(schema, row) + fields, err := scanRow(schema, row) + if err != nil { + return err + } + model.MapToStruct(schema, fields, v) + return nil } -func (d *Database) Update(ctx context.Context, schema *model.Schema, key string, fields map[string]any) error { +func (d *sqliteModel) Update(ctx context.Context, v interface{}) error { + schema, err := d.schema(v) + if err != nil { + return err + } + fields := model.StructToMap(schema, v) + key := model.KeyValue(schema, v) setClauses, values := buildUpdate(schema, fields) values = append(values, key) query := fmt.Sprintf("UPDATE %q SET %s WHERE %q = ?", schema.Table, setClauses, schema.Key) @@ -103,7 +147,11 @@ func (d *Database) Update(ctx context.Context, schema *model.Schema, key string, return nil } -func (d *Database) Delete(ctx context.Context, schema *model.Schema, key string) error { +func (d *sqliteModel) Delete(ctx context.Context, key string, v interface{}) error { + schema, err := d.schema(v) + if err != nil { + return err + } query := fmt.Sprintf("DELETE FROM %q WHERE %q = ?", schema.Table, schema.Key) result, err := d.db.ExecContext(ctx, query, key) if err != nil { @@ -116,7 +164,26 @@ func (d *Database) Delete(ctx context.Context, schema *model.Schema, key string) return nil } -func (d *Database) List(ctx context.Context, schema *model.Schema, opts ...model.QueryOption) ([]map[string]any, error) { +func (d *sqliteModel) List(ctx context.Context, result interface{}, opts ...model.QueryOption) error { + // result must be *[]*T + rv := reflect.ValueOf(result) + if rv.Kind() != reflect.Ptr || rv.Elem().Kind() != reflect.Slice { + return fmt.Errorf("model/sqlite: result must be a pointer to a slice") + } + sliceVal := rv.Elem() + elemType := sliceVal.Type().Elem() + structType := elemType + if structType.Kind() == reflect.Ptr { + structType = structType.Elem() + } + + d.mu.RLock() + schema, ok := d.types[structType] + d.mu.RUnlock() + if !ok { + return model.ErrNotRegistered + } + q := model.ApplyQueryOptions(opts...) cols := columnList(schema) @@ -146,14 +213,34 @@ func (d *Database) List(ctx context.Context, schema *model.Schema, opts ...model rows, err := d.db.QueryContext(ctx, query, args...) if err != nil { - return nil, fmt.Errorf("model/sqlite: list: %w", err) + return fmt.Errorf("model/sqlite: list: %w", err) } defer rows.Close() - return scanRows(schema, rows) + fieldMaps, err := scanRows(schema, rows) + if err != nil { + return err + } + + results := reflect.MakeSlice(sliceVal.Type(), len(fieldMaps), len(fieldMaps)) + for i, fields := range fieldMaps { + vp := reflect.New(structType) + model.MapToStruct(schema, fields, vp.Interface()) + if elemType.Kind() == reflect.Ptr { + results.Index(i).Set(vp) + } else { + results.Index(i).Set(vp.Elem()) + } + } + sliceVal.Set(results) + return nil } -func (d *Database) Count(ctx context.Context, schema *model.Schema, opts ...model.QueryOption) (int64, error) { +func (d *sqliteModel) Count(ctx context.Context, v interface{}, opts ...model.QueryOption) (int64, error) { + schema, err := d.schema(v) + if err != nil { + return 0, err + } q := model.ApplyQueryOptions(opts...) query := fmt.Sprintf("SELECT COUNT(*) FROM %q", schema.Table) @@ -166,18 +253,18 @@ func (d *Database) Count(ctx context.Context, schema *model.Schema, opts ...mode } var count int64 - err := d.db.QueryRowContext(ctx, query, args...).Scan(&count) + err = d.db.QueryRowContext(ctx, query, args...).Scan(&count) if err != nil { return 0, fmt.Errorf("model/sqlite: count: %w", err) } return count, nil } -func (d *Database) Close() error { +func (d *sqliteModel) Close() error { return d.db.Close() } -func (d *Database) String() string { +func (d *sqliteModel) String() string { return "sqlite" } @@ -281,7 +368,6 @@ func scanRows(schema *model.Schema, rows *sql.Rows) ([]map[string]any, error) { return results, rows.Err() } -// newScanPtr returns a pointer suitable for sql.Scan based on the Go type. func newScanPtr(t reflect.Type) any { switch t.Kind() { case reflect.String: @@ -301,7 +387,6 @@ func newScanPtr(t reflect.Type) any { } } -// derefScanPtr extracts the scanned value and converts to the target Go type. func derefScanPtr(ptr any, t reflect.Type) any { rv := reflect.ValueOf(ptr).Elem() if rv.Type().ConvertibleTo(t) { diff --git a/model/sqlite/sqlite_test.go b/model/sqlite/sqlite_test.go index 3618a24fc9..202383d594 100644 --- a/model/sqlite/sqlite_test.go +++ b/model/sqlite/sqlite_test.go @@ -14,24 +14,28 @@ type User struct { Age int `json:"age"` } -func setup(t *testing.T) *model.Model[User] { +func setup(t *testing.T) model.Model { t.Helper() db := New(":memory:") - return model.New[User](db) + if err := db.Register(&User{}); err != nil { + t.Fatalf("register: %v", err) + } + return db } func TestCRUD(t *testing.T) { - users := setup(t) + db := setup(t) ctx := context.Background() // Create - err := users.Create(ctx, &User{ID: "1", Name: "Alice", Email: "alice@test.com", Age: 30}) + err := db.Create(ctx, &User{ID: "1", Name: "Alice", Email: "alice@test.com", Age: 30}) if err != nil { t.Fatalf("create: %v", err) } // Read - u, err := users.Read(ctx, "1") + u := &User{} + err = db.Read(ctx, "1", u) if err != nil { t.Fatalf("read: %v", err) } @@ -45,12 +49,13 @@ func TestCRUD(t *testing.T) { // Update u.Name = "Alice Updated" u.Age = 31 - err = users.Update(ctx, u) + err = db.Update(ctx, u) if err != nil { t.Fatalf("update: %v", err) } - u2, _ := users.Read(ctx, "1") + u2 := &User{} + db.Read(ctx, "1", u2) if u2.Name != "Alice Updated" { t.Errorf("expected 'Alice Updated', got %s", u2.Name) } @@ -59,57 +64,58 @@ func TestCRUD(t *testing.T) { } // Delete - err = users.Delete(ctx, "1") + err = db.Delete(ctx, "1", &User{}) if err != nil { t.Fatalf("delete: %v", err) } - _, err = users.Read(ctx, "1") + err = db.Read(ctx, "1", &User{}) if err != model.ErrNotFound { t.Errorf("expected ErrNotFound, got %v", err) } } func TestDuplicateKey(t *testing.T) { - users := setup(t) + db := setup(t) ctx := context.Background() - users.Create(ctx, &User{ID: "1", Name: "Alice"}) - err := users.Create(ctx, &User{ID: "1", Name: "Bob"}) + db.Create(ctx, &User{ID: "1", Name: "Alice"}) + err := db.Create(ctx, &User{ID: "1", Name: "Bob"}) if err != model.ErrDuplicateKey { t.Errorf("expected ErrDuplicateKey, got %v", err) } } func TestNotFound(t *testing.T) { - users := setup(t) + db := setup(t) ctx := context.Background() - _, err := users.Read(ctx, "nonexistent") + err := db.Read(ctx, "nonexistent", &User{}) if err != model.ErrNotFound { t.Errorf("expected ErrNotFound, got %v", err) } - err = users.Update(ctx, &User{ID: "nonexistent"}) + err = db.Update(ctx, &User{ID: "nonexistent"}) if err != model.ErrNotFound { t.Errorf("expected ErrNotFound on update, got %v", err) } - err = users.Delete(ctx, "nonexistent") + err = db.Delete(ctx, "nonexistent", &User{}) if err != model.ErrNotFound { t.Errorf("expected ErrNotFound on delete, got %v", err) } } func TestListWithFilter(t *testing.T) { - users := setup(t) + db := setup(t) ctx := context.Background() - users.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30}) - users.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25}) - users.Create(ctx, &User{ID: "3", Name: "Alice", Age: 35}) + db.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30}) + db.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25}) + db.Create(ctx, &User{ID: "3", Name: "Alice", Age: 35}) - results, err := users.List(ctx, model.Where("name", "Alice")) + var results []*User + err := db.List(ctx, &results, model.Where("name", "Alice")) if err != nil { t.Fatalf("list: %v", err) } @@ -119,14 +125,15 @@ func TestListWithFilter(t *testing.T) { } func TestListWithOrder(t *testing.T) { - users := setup(t) + db := setup(t) ctx := context.Background() - users.Create(ctx, &User{ID: "1", Name: "Charlie", Age: 35}) - users.Create(ctx, &User{ID: "2", Name: "Alice", Age: 30}) - users.Create(ctx, &User{ID: "3", Name: "Bob", Age: 25}) + db.Create(ctx, &User{ID: "1", Name: "Charlie", Age: 35}) + db.Create(ctx, &User{ID: "2", Name: "Alice", Age: 30}) + db.Create(ctx, &User{ID: "3", Name: "Bob", Age: 25}) - results, err := users.List(ctx, model.OrderAsc("name")) + var results []*User + err := db.List(ctx, &results, model.OrderAsc("name")) if err != nil { t.Fatalf("list: %v", err) } @@ -142,15 +149,16 @@ func TestListWithOrder(t *testing.T) { } func TestListWithLimitOffset(t *testing.T) { - users := setup(t) + db := setup(t) ctx := context.Background() - users.Create(ctx, &User{ID: "1", Name: "A", Age: 1}) - users.Create(ctx, &User{ID: "2", Name: "B", Age: 2}) - users.Create(ctx, &User{ID: "3", Name: "C", Age: 3}) - users.Create(ctx, &User{ID: "4", Name: "D", Age: 4}) + db.Create(ctx, &User{ID: "1", Name: "A", Age: 1}) + db.Create(ctx, &User{ID: "2", Name: "B", Age: 2}) + db.Create(ctx, &User{ID: "3", Name: "C", Age: 3}) + db.Create(ctx, &User{ID: "4", Name: "D", Age: 4}) - results, err := users.List(ctx, + var results []*User + err := db.List(ctx, &results, model.OrderAsc("name"), model.Limit(2), model.Offset(1), @@ -170,14 +178,14 @@ func TestListWithLimitOffset(t *testing.T) { } func TestCount(t *testing.T) { - users := setup(t) + db := setup(t) ctx := context.Background() - users.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30}) - users.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25}) - users.Create(ctx, &User{ID: "3", Name: "Alice", Age: 35}) + db.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30}) + db.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25}) + db.Create(ctx, &User{ID: "3", Name: "Alice", Age: 35}) - count, err := users.Count(ctx) + count, err := db.Count(ctx, &User{}) if err != nil { t.Fatalf("count: %v", err) } @@ -185,7 +193,7 @@ func TestCount(t *testing.T) { t.Errorf("expected 3, got %d", count) } - count, err = users.Count(ctx, model.Where("name", "Alice")) + count, err = db.Count(ctx, &User{}, model.Where("name", "Alice")) if err != nil { t.Fatalf("count with filter: %v", err) } @@ -195,14 +203,15 @@ func TestCount(t *testing.T) { } func TestWhereOp(t *testing.T) { - users := setup(t) + db := setup(t) ctx := context.Background() - users.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30}) - users.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25}) - users.Create(ctx, &User{ID: "3", Name: "Charlie", Age: 35}) + db.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30}) + db.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25}) + db.Create(ctx, &User{ID: "3", Name: "Charlie", Age: 35}) - results, err := users.List(ctx, model.WhereOp("age", ">", 28)) + var results []*User + err := db.List(ctx, &results, model.WhereOp("age", ">", 28)) if err != nil { t.Fatalf("list: %v", err) } @@ -210,15 +219,3 @@ func TestWhereOp(t *testing.T) { t.Errorf("expected 2 (age > 28), got %d", len(results)) } } - -func TestSchema(t *testing.T) { - users := setup(t) - schema := users.Schema() - - if schema.Table != "users" { - t.Errorf("expected table 'users', got %q", schema.Table) - } - if schema.Key != "id" { - t.Errorf("expected key 'id', got %q", schema.Key) - } -} diff --git a/service/options.go b/service/options.go index 5893f5ab58..136474dd58 100644 --- a/service/options.go +++ b/service/options.go @@ -32,7 +32,7 @@ type Options struct { Config config.Config Client client.Client Server server.Server - Model model.Database + Model model.Model // Other options for implementations of the interface // can be stored in a context @@ -158,10 +158,10 @@ func Store(s store.Store) Option { } } -// Model sets the model database to use. -func Model(db model.Database) Option { +// Model sets the model backend to use. +func Model(m model.Model) Option { return func(o *Options) { - o.Model = db + o.Model = m } } diff --git a/service/service.go b/service/service.go index 768cbc20c8..e01e421e88 100644 --- a/service/service.go +++ b/service/service.go @@ -29,8 +29,8 @@ type Service interface { Client() client.Client // Server returns the RPC server. Server() server.Server - // Model returns the data model database. - Model() model.Database + // Model returns the data model backend. + Model() model.Model // Start the service (non-blocking). Start() error // Stop the service. @@ -108,7 +108,7 @@ func (s *serviceImpl) Server() server.Server { return s.opts.Server } -func (s *serviceImpl) Model() model.Database { +func (s *serviceImpl) Model() model.Model { return s.opts.Model } From 0ce954887958485b102162626ae34fadf9dc2314 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Mar 2026 12:20:39 +0000 Subject: [PATCH 5/5] docs: clarify blog post 7 uses modular monolith, not multi-service Blog post 7 demonstrated all handlers in a single process but framed it as microservices without acknowledging the architectural difference. - Add "A Note on Architecture" section explaining this is a modular monolith demo and pointing to micro/blog for multi-service - Clarify that handlers can be broken out into separate services later - Fix "service registry" language to match single-process reality - Restructure "Adding MCP to Existing Services" to distinguish the in-process approach from registry-based gateway options - Update closing to acknowledge both paradigms - Fix README type names (&CommentService{} -> &Comments{}, etc.) https://claude.ai/code/session_01GkduEhcrqcG45rdfYh8dAc --- examples/mcp/platform/README.md | 4 ++-- internal/website/blog/7.md | 32 +++++++++++++++++--------------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/examples/mcp/platform/README.md b/examples/mcp/platform/README.md index 6dd70601c7..c4ab7f863e 100644 --- a/examples/mcp/platform/README.md +++ b/examples/mcp/platform/README.md @@ -65,8 +65,8 @@ service := micro.New("platform", service.Handle(users) service.Handle(posts) -service.Handle(&CommentService{}) -service.Handle(&MailService{}) +service.Handle(&Comments{}) +service.Handle(&Mail{}) ``` Each handler method becomes an MCP tool. The `@example` tags in doc comments give agents sample inputs to learn from. diff --git a/internal/website/blog/7.md b/internal/website/blog/7.md index 3581b4634c..143ea9a998 100644 --- a/internal/website/blog/7.md +++ b/internal/website/blog/7.md @@ -13,16 +13,20 @@ Here's the pitch: you have microservices. They already have well-defined endpoin With Go Micro + MCP, that gap is **zero lines of code**. -## The Setup: A Real Blogging Platform +## The Setup: A Blogging Platform -We'll use [micro/blog](https://github.com/micro/blog) as our example — a real microblogging platform built on Go Micro with four services: +We'll use a blogging platform as our example — inspired by [micro/blog](https://github.com/micro/blog), a real microblogging platform built on Go Micro with four domains: - **Users** — signup, login, profiles - **Posts** — blog posts with markdown, tags, link previews - **Comments** — threaded comments on posts - **Mail** — internal messaging -These services exist today. They were built for human users interacting through a web UI. No one was thinking about AI agents when they were written. +### A Note on Architecture + +Go Micro has always been a framework for building **multi-service, multi-process** systems. The [micro/blog](https://github.com/micro/blog) platform is a great example — each service runs as its own binary, communicates over RPC, and is independently deployable. If that's what you're after, check it out. + +For this walkthrough, we take a different approach: a **modular monolith**. All four domains live in a single process. This is a perfectly valid starting point — you get the clean separation of handler interfaces without the operational overhead of multiple services. And because Go Micro's handler registration works the same way in both models, you can break these out into separate services later as your team or requirements grow. No rewrite needed. ## One Line to Agent-Enable Everything @@ -40,7 +44,7 @@ service.Handle(&Mail{}) That `mcp.WithMCP(":3001")` starts an MCP gateway that: -1. Discovers all registered handlers from the service registry +1. Discovers all registered handlers on the service 2. Converts Go method signatures into JSON tool schemas 3. Extracts descriptions from doc comments 4. Serves it all as MCP-compliant tool definitions @@ -156,21 +160,17 @@ type CreatePostRequest struct { ## Adding MCP to Existing Services -If you already have Go Micro services running (like micro/blog), you have three options: +This demo runs everything in one process, but if you already have Go Micro services running as separate processes (like [micro/blog](https://github.com/micro/blog)), you have two additional options beyond the in-process approach shown above: -### Option 1: One-line in your service -```go -service := micro.New("blog", - mcp.WithMCP(":3001"), // Add this line -) -``` +### Option 1: Standalone gateway binary + +Point a gateway at your service registry and it discovers all running services automatically: -### Option 2: Standalone gateway binary ```bash micro-mcp-gateway --registry consul:8500 --address :3001 ``` -### Option 3: Sidecar in your deployment +### Option 2: Sidecar in your deployment ```yaml # docker-compose.yml services: @@ -184,7 +184,7 @@ services: - "3001:3001" ``` -All three discover services from the same registry. Zero changes to your service code. +Both discover services from the registry and expose them as MCP tools. Zero changes to your service code. ## Production Considerations @@ -220,4 +220,6 @@ The full example is at [`examples/mcp/platform/`](https://github.com/micro/go-mi We're working on a Kubernetes operator that automatically deploys MCP gateways alongside your services, request/response caching to reduce redundant calls from agents, and multi-tenant namespace isolation. See the [roadmap](/docs/roadmap-2026) for details. -The core idea is simple: microservices already have the right structure for AI tools. We just needed to bridge the protocol gap. With MCP, that bridge is one line of code. +The core idea is simple: well-structured services — whether running as a modular monolith or as independently deployed microservices — already have the right shape for AI tools. We just needed to bridge the protocol gap. With MCP, that bridge is one line of code. + +Whether you start with a single process like this demo or go straight to multi-service like [micro/blog](https://github.com/micro/blog), the MCP integration works the same way.