api: Use Huma to generate swagger docs and parse JSON automatically (#262)
## Motivation and Context Currently there's a lot of unsafe manual JSON unmarshalling/marshalling, and we have to manually keep the openapi schema in sync. This PR migrates the API to [Huma](https://github.com/danielgtaylor/huma), which appears like a popular, well-maintained way to build APIs in Golang. I'm not a Golang expert, but seemed highly recommended by Reddit + Claude and has a decent star count. It provides automatic openapi spec generation and docs hosting, JSON parsing, as well as the ability to add auth middlewares etc. ## How Has This Been Tested? Running locally, and poking at the endpoints ## Breaking Changes Docs are served at `/docs` rather than `/v0/swagger/index.html` now ## Types of changes <!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply: --> - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [x] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update ## Checklist <!-- Go over all the following points, and put an `x` in all the boxes that apply. --> - [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) - [x] My code follows the repository's style guidelines - [x] New and existing tests pass locally - [x] I have added appropriate error handling - [x] I have added or updated documentation as needed ## Additional context <!-- Add any other context, implementation notes, or design decisions -->
This commit is contained in:
parent
122dbf6a68
commit
6b84493ca9
13 changed files with 1622 additions and 2267 deletions
|
|
@ -1,126 +1,101 @@
|
|||
// Package v0 contains API handlers for version 0 of the API
|
||||
package v0
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/modelcontextprotocol/registry/internal/auth"
|
||||
"github.com/modelcontextprotocol/registry/internal/model"
|
||||
)
|
||||
|
||||
// StartAuthHandler handles requests to start an authentication flow
|
||||
func StartAuthHandler(authService auth.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Only allow POST method
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
// StartAuthInput represents the input for starting an auth flow
|
||||
type StartAuthInput struct {
|
||||
Body struct {
|
||||
Method string `json:"method" doc:"Authentication method" example:"github"`
|
||||
RepoRef string `json:"repo_ref" doc:"Repository reference" example:"owner/repo"`
|
||||
}
|
||||
}
|
||||
|
||||
// Read the request body
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Error reading request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
// StartAuthBody represents the auth flow start response body
|
||||
type StartAuthBody struct {
|
||||
AuthFlowInfo map[string]string `json:"auth_flow_info" doc:"Authentication flow information"`
|
||||
StatusToken string `json:"status_token" doc:"Token to check auth status"`
|
||||
}
|
||||
|
||||
// Parse request body into AuthRequest struct
|
||||
var authReq struct {
|
||||
Method string `json:"method"`
|
||||
RepoRef string `json:"repo_ref"`
|
||||
}
|
||||
err = json.Unmarshal(body, &authReq)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid request payload: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// CheckAuthStatusInput represents the input for checking auth status
|
||||
type CheckAuthStatusInput struct {
|
||||
StatusToken string `path:"token" doc:"Status token from auth flow start"`
|
||||
}
|
||||
|
||||
// CheckAuthStatusBody represents the auth status response body
|
||||
type CheckAuthStatusBody struct {
|
||||
Status string `json:"status" doc:"Authentication status" example:"pending"`
|
||||
}
|
||||
|
||||
// RegisterAuthEndpoints registers all auth-related endpoints
|
||||
func RegisterAuthEndpoints(api huma.API, authService auth.Service) {
|
||||
// Start auth flow endpoint
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "start-auth",
|
||||
Method: http.MethodPost,
|
||||
Path: "/v0/auth/start",
|
||||
Summary: "Start authentication flow",
|
||||
Description: "Start an authentication flow for publishing servers",
|
||||
Tags: []string{"auth"},
|
||||
}, func(ctx context.Context, input *StartAuthInput) (*Response[StartAuthBody], error) {
|
||||
// Validate required fields
|
||||
if authReq.Method == "" {
|
||||
http.Error(w, "Auth method is required", http.StatusBadRequest)
|
||||
return
|
||||
if input.Body.Method == "" {
|
||||
return nil, huma.Error400BadRequest("Auth method is required")
|
||||
}
|
||||
if input.Body.RepoRef == "" {
|
||||
return nil, huma.Error400BadRequest("Repository reference is required")
|
||||
}
|
||||
|
||||
// Convert string method to enum type
|
||||
var method model.AuthMethod
|
||||
switch authReq.Method {
|
||||
// Convert string method to AuthMethod type
|
||||
var authMethod model.AuthMethod
|
||||
switch input.Body.Method {
|
||||
case "github":
|
||||
method = model.AuthMethodGitHub
|
||||
authMethod = model.AuthMethodGitHub
|
||||
case "none":
|
||||
authMethod = model.AuthMethodNone
|
||||
default:
|
||||
http.Error(w, "Unsupported authentication method", http.StatusBadRequest)
|
||||
return
|
||||
return nil, huma.Error400BadRequest("Invalid auth method: " + input.Body.Method)
|
||||
}
|
||||
|
||||
// Start auth flow
|
||||
flowInfo, statusToken, err := authService.StartAuthFlow(r.Context(), method, authReq.RepoRef)
|
||||
// Start the auth flow
|
||||
flowInfo, statusToken, err := authService.StartAuthFlow(ctx, authMethod, input.Body.RepoRef)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to start auth flow: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
return nil, huma.Error500InternalServerError("Failed to start auth flow", err)
|
||||
}
|
||||
|
||||
// Return successful response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"flow_info": flowInfo,
|
||||
"status_token": statusToken,
|
||||
"expires_in": 300, // 5 minutes
|
||||
}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
return &Response[StartAuthBody]{
|
||||
Body: StartAuthBody{
|
||||
AuthFlowInfo: flowInfo,
|
||||
StatusToken: statusToken,
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
|
||||
// CheckAuthStatusHandler handles requests to check the status of an authentication flow
|
||||
func CheckAuthStatusHandler(authService auth.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Only allow GET method
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Get status token from query parameter
|
||||
statusToken := r.URL.Query().Get("token")
|
||||
if statusToken == "" {
|
||||
http.Error(w, "Status token is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check auth status
|
||||
token, err := authService.CheckAuthStatus(r.Context(), statusToken)
|
||||
// Check auth status endpoint
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "check-auth-status",
|
||||
Method: http.MethodGet,
|
||||
Path: "/v0/auth/status/{token}",
|
||||
Summary: "Check authentication status",
|
||||
Description: "Check the status of an ongoing authentication flow",
|
||||
Tags: []string{"auth"},
|
||||
}, func(ctx context.Context, input *CheckAuthStatusInput) (*Response[CheckAuthStatusBody], error) {
|
||||
// Check the auth status
|
||||
status, err := authService.CheckAuthStatus(ctx, input.StatusToken)
|
||||
if err != nil {
|
||||
if err.Error() == "pending" {
|
||||
// Auth is still pending
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "pending",
|
||||
}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Other error
|
||||
http.Error(w, "Failed to check auth status: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
return nil, huma.Error404NotFound("Auth flow not found or expired")
|
||||
}
|
||||
|
||||
// Authentication completed successfully
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "complete",
|
||||
"token": token,
|
||||
}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
return &Response[CheckAuthStatusBody]{
|
||||
Body: CheckAuthStatusBody{
|
||||
Status: status,
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
|
@ -1,27 +1,34 @@
|
|||
// Package v0 contains API handlers for version 0 of the API
|
||||
package v0
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/modelcontextprotocol/registry/internal/config"
|
||||
)
|
||||
|
||||
type HealthResponse struct {
|
||||
Status string `json:"status"`
|
||||
GitHubClientID string `json:"github_client_id"`
|
||||
// HealthBody represents the health check response body
|
||||
type HealthBody struct {
|
||||
Status string `json:"status" example:"ok" doc:"Health status"`
|
||||
GitHubClientID string `json:"github_client_id,omitempty" doc:"GitHub OAuth App Client ID"`
|
||||
}
|
||||
|
||||
// HealthHandler returns a handler for health check endpoint
|
||||
func HealthHandler(cfg *config.Config) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(HealthResponse{
|
||||
Status: "ok",
|
||||
GitHubClientID: cfg.GithubClientID,
|
||||
}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
// RegisterHealthEndpoint registers the health check endpoint
|
||||
func RegisterHealthEndpoint(api huma.API, cfg *config.Config) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "get-health",
|
||||
Method: http.MethodGet,
|
||||
Path: "/v0/health",
|
||||
Summary: "Health check",
|
||||
Description: "Check the health status of the API",
|
||||
Tags: []string{"health"},
|
||||
}, func(_ context.Context, _ *struct{}) (*Response[HealthBody], error) {
|
||||
return &Response[HealthBody]{
|
||||
Body: HealthBody{
|
||||
Status: "ok",
|
||||
GitHubClientID: cfg.GithubClientID,
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
package v0_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/danielgtaylor/huma/v2/adapters/humago"
|
||||
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
|
||||
"github.com/modelcontextprotocol/registry/internal/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestHealthHandler(t *testing.T) {
|
||||
func TestHealthEndpoint(t *testing.T) {
|
||||
// Test cases
|
||||
testCases := []struct {
|
||||
name string
|
||||
config *config.Config
|
||||
expectedStatus int
|
||||
expectedBody v0.HealthResponse
|
||||
expectedBody v0.HealthBody
|
||||
}{
|
||||
{
|
||||
name: "returns health status with github client id",
|
||||
|
|
@ -26,18 +26,18 @@ func TestHealthHandler(t *testing.T) {
|
|||
GithubClientID: "test-github-client-id",
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: v0.HealthResponse{
|
||||
expectedBody: v0.HealthBody{
|
||||
Status: "ok",
|
||||
GitHubClientID: "test-github-client-id",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "works with empty github client id",
|
||||
name: "returns health status without github client id",
|
||||
config: &config.Config{
|
||||
GithubClientID: "",
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: v0.HealthResponse{
|
||||
expectedBody: v0.HealthBody{
|
||||
Status: "ok",
|
||||
GitHubClientID: "",
|
||||
},
|
||||
|
|
@ -46,77 +46,33 @@ func TestHealthHandler(t *testing.T) {
|
|||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Create handler with the test config
|
||||
handler := v0.HealthHandler(tc.config)
|
||||
// Create a new test API
|
||||
mux := http.NewServeMux()
|
||||
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
||||
|
||||
// Register the health endpoint
|
||||
v0.RegisterHealthEndpoint(api, tc.config)
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/health", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Create a test request
|
||||
req := httptest.NewRequest(http.MethodGet, "/v0/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Create response recorder
|
||||
rr := httptest.NewRecorder()
|
||||
// Serve the request
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
// Call the handler
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
// Check status code
|
||||
assert.Equal(t, tc.expectedStatus, rr.Code)
|
||||
|
||||
// Check content type
|
||||
assert.Equal(t, "application/json", rr.Header().Get("Content-Type"))
|
||||
|
||||
// Parse response body
|
||||
var resp v0.HealthResponse
|
||||
err = json.NewDecoder(rr.Body).Decode(&resp)
|
||||
assert.NoError(t, err)
|
||||
// Check the status code
|
||||
assert.Equal(t, tc.expectedStatus, w.Code)
|
||||
|
||||
// Check the response body
|
||||
assert.Equal(t, tc.expectedBody, resp)
|
||||
// Since Huma adds a $schema field, we'll check individual fields
|
||||
body := w.Body.String()
|
||||
assert.Contains(t, body, `"status":"ok"`)
|
||||
|
||||
if tc.config.GithubClientID != "" {
|
||||
assert.Contains(t, body, `"github_client_id":"test-github-client-id"`)
|
||||
} else {
|
||||
assert.NotContains(t, body, `"github_client_id"`)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealthHandlerIntegration tests the handler with actual HTTP requests
|
||||
func TestHealthHandlerIntegration(t *testing.T) {
|
||||
// Create test server
|
||||
cfg := &config.Config{
|
||||
GithubClientID: "integration-test-client-id",
|
||||
}
|
||||
|
||||
server := httptest.NewServer(v0.HealthHandler(cfg))
|
||||
defer server.Close()
|
||||
|
||||
// Send request to the test server
|
||||
ctx := context.Background()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check status code
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Check content type
|
||||
assert.Equal(t, "application/json", resp.Header.Get("Content-Type"))
|
||||
|
||||
// Parse response body
|
||||
var healthResp v0.HealthResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&healthResp)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Check the response body
|
||||
expectedResp := v0.HealthResponse{
|
||||
Status: "ok",
|
||||
GitHubClientID: "integration-test-client-id",
|
||||
}
|
||||
assert.Equal(t, expectedResp, healthResp)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
package v0_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/modelcontextprotocol/registry/internal/model"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// MockRegistryService is a mock implementation of the RegistryService interface
|
||||
type MockRegistryService struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockRegistryService) List(cursor string, limit int) ([]model.Server, string, error) {
|
||||
args := m.Called(cursor, limit)
|
||||
return args.Get(0).([]model.Server), args.String(1), args.Error(2)
|
||||
}
|
||||
|
||||
func (m *MockRegistryService) GetByID(id string) (*model.ServerDetail, error) {
|
||||
args := m.Called(id)
|
||||
return args.Get(0).(*model.ServerDetail), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockRegistryService) Publish(serverDetail *model.ServerDetail) error {
|
||||
args := m.Called(serverDetail)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// MockAuthService is a mock implementation of the auth.Service interface
|
||||
type MockAuthService struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockAuthService) StartAuthFlow(
|
||||
ctx context.Context, method model.AuthMethod, repoRef string,
|
||||
) (map[string]string, string, error) {
|
||||
args := m.Called(ctx, method, repoRef)
|
||||
return args.Get(0).(map[string]string), args.String(1), args.Error(2)
|
||||
}
|
||||
|
||||
func (m *MockAuthService) CheckAuthStatus(ctx context.Context, statusToken string) (string, error) {
|
||||
args := m.Called(ctx, statusToken)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockAuthService) ValidateAuth(ctx context.Context, authentication model.Authentication) (bool, error) {
|
||||
args := m.Called(ctx, authentication)
|
||||
return args.Bool(0), args.Error(1)
|
||||
}
|
||||
|
|
@ -1,29 +1,31 @@
|
|||
// Package v0 contains API handlers for version 0 of the API
|
||||
package v0
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/modelcontextprotocol/registry/internal/config"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
// PingHandler returns a handler for the ping endpoint that returns build version
|
||||
func PingHandler(cfg *config.Config) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]string{
|
||||
"status": "ok",
|
||||
"version": cfg.Version,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
// PingBody represents the ping response body
|
||||
type PingBody struct {
|
||||
Pong bool `json:"pong" example:"true" doc:"Ping response"`
|
||||
}
|
||||
|
||||
// RegisterPingEndpoint registers the ping endpoint
|
||||
func RegisterPingEndpoint(api huma.API) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "ping",
|
||||
Method: http.MethodGet,
|
||||
Path: "/v0/ping",
|
||||
Summary: "Ping",
|
||||
Description: "Simple ping endpoint",
|
||||
Tags: []string{"ping"},
|
||||
}, func(_ context.Context, _ *struct{}) (*Response[PingBody], error) {
|
||||
return &Response[PingBody]{
|
||||
Body: PingBody{
|
||||
Pong: true,
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
|
|
|||
35
internal/api/handlers/v0/ping_test.go
Normal file
35
internal/api/handlers/v0/ping_test.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package v0_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/danielgtaylor/huma/v2/adapters/humago"
|
||||
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPingEndpoint(t *testing.T) {
|
||||
// Create a new test API
|
||||
mux := http.NewServeMux()
|
||||
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
||||
|
||||
// Register the ping endpoint
|
||||
v0.RegisterPingEndpoint(api)
|
||||
|
||||
// Create a test request
|
||||
req := httptest.NewRequest(http.MethodGet, "/v0/ping", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Serve the request
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
// Check the status code
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Check the response body contains pong
|
||||
body := w.Body.String()
|
||||
assert.Contains(t, body, `"pong":true`)
|
||||
}
|
||||
|
|
@ -1,133 +1,96 @@
|
|||
// Package v0 contains API handlers for version 0 of the API
|
||||
package v0
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/modelcontextprotocol/registry/internal/auth"
|
||||
"github.com/modelcontextprotocol/registry/internal/database"
|
||||
"github.com/modelcontextprotocol/registry/internal/model"
|
||||
"github.com/modelcontextprotocol/registry/internal/service"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// PublishHandler handles requests to publish new server details to the registry
|
||||
func PublishHandler(registry service.RegistryService, authService auth.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Only allow POST method
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
// PublishServerInput represents the input for publishing a server
|
||||
type PublishServerInput struct {
|
||||
Authorization string `header:"Authorization" doc:"GitHub OAuth token" required:"true"`
|
||||
Body model.PublishRequest
|
||||
}
|
||||
|
||||
// Read the request body
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Error reading request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
// Parse request body into PublishRequest struct
|
||||
var publishReq model.PublishRequest
|
||||
err = json.Unmarshal(body, &publishReq)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid request payload: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
// RegisterPublishEndpoint registers the publish endpoint
|
||||
func RegisterPublishEndpoint(api huma.API, registry service.RegistryService, authService auth.Service) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "publish-server",
|
||||
Method: http.MethodPost,
|
||||
Path: "/v0/publish",
|
||||
Summary: "Publish MCP server",
|
||||
Description: "Publish a new MCP server to the registry or update an existing one",
|
||||
Tags: []string{"publish"},
|
||||
Security: []map[string][]string{
|
||||
{"bearer": {}},
|
||||
},
|
||||
}, func(ctx context.Context, input *PublishServerInput) (*Response[model.Server], error) {
|
||||
// Extract bearer token
|
||||
const bearerPrefix = "Bearer "
|
||||
authHeader := input.Authorization
|
||||
if len(authHeader) < len(bearerPrefix) || !strings.EqualFold(authHeader[:len(bearerPrefix)], bearerPrefix) {
|
||||
return nil, huma.Error401Unauthorized("Invalid Authorization header format. Expected 'Bearer <token>'")
|
||||
}
|
||||
token := authHeader[len(bearerPrefix):]
|
||||
|
||||
// Get server details from the request
|
||||
var serverDetail model.ServerDetail
|
||||
// Convert PublishRequest body to ServerDetail
|
||||
serverDetail := input.Body.ServerDetail
|
||||
|
||||
err = json.Unmarshal(body, &serverDetail)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid server detail payload: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Validate required fields
|
||||
// Huma handles validation automatically based on struct tags
|
||||
// But we can add custom validation if needed
|
||||
if serverDetail.Name == "" {
|
||||
http.Error(w, "Name is required", http.StatusBadRequest)
|
||||
return
|
||||
return nil, huma.Error400BadRequest("Name is required")
|
||||
}
|
||||
|
||||
// Version is required
|
||||
if serverDetail.VersionDetail.Version == "" {
|
||||
http.Error(w, "Version is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get auth token from Authorization header
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
http.Error(w, "Authorization header is required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle bearer token format (e.g., "Bearer xyz123")
|
||||
token := authHeader
|
||||
if len(authHeader) > 7 && strings.ToUpper(authHeader[:7]) == "BEARER " {
|
||||
token = authHeader[7:]
|
||||
return nil, huma.Error400BadRequest("Version is required")
|
||||
}
|
||||
|
||||
// Determine authentication method based on server name prefix
|
||||
var authMethod model.AuthMethod
|
||||
switch {
|
||||
case strings.HasPrefix(serverDetail.Name, "io.github"):
|
||||
if strings.HasPrefix(serverDetail.Name, "io.github") {
|
||||
authMethod = model.AuthMethodGitHub
|
||||
// Additional cases can be added here for other prefixes
|
||||
default:
|
||||
// Keep the default auth method as AuthMethodNone
|
||||
} else {
|
||||
authMethod = model.AuthMethodNone
|
||||
}
|
||||
|
||||
serverName := html.EscapeString(serverDetail.Name)
|
||||
|
||||
// Setup authentication info
|
||||
a := model.Authentication{
|
||||
Method: authMethod,
|
||||
Token: token,
|
||||
RepoRef: serverName,
|
||||
RepoRef: serverDetail.Name,
|
||||
}
|
||||
|
||||
valid, err := authService.ValidateAuth(r.Context(), a)
|
||||
// Validate authentication
|
||||
valid, err := authService.ValidateAuth(ctx, a)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrAuthRequired) {
|
||||
http.Error(w, "Authentication is required for publishing", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Authentication failed: "+err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
return nil, huma.Error401Unauthorized("Authentication failed", err)
|
||||
}
|
||||
|
||||
if !valid {
|
||||
http.Error(w, "Invalid authentication credentials", http.StatusUnauthorized)
|
||||
return
|
||||
return nil, huma.Error401Unauthorized("Invalid authentication credentials")
|
||||
}
|
||||
|
||||
// Call the publish method on the registry service
|
||||
// Publish the server details
|
||||
err = registry.Publish(&serverDetail)
|
||||
if err != nil {
|
||||
// Check for specific error types and return appropriate HTTP status codes
|
||||
if errors.Is(err, database.ErrInvalidVersion) || errors.Is(err, database.ErrAlreadyExists) {
|
||||
http.Error(w, "Failed to publish server details: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Failed to publish server details: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
return nil, huma.Error500InternalServerError("Failed to publish server", err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "Server publication successful",
|
||||
"id": serverDetail.ID,
|
||||
}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Create response with the published server data
|
||||
return &Response[model.Server]{
|
||||
Body: model.Server{
|
||||
ID: serverDetail.ID,
|
||||
Name: serverDetail.Name,
|
||||
Description: serverDetail.Description,
|
||||
Status: serverDetail.Status,
|
||||
Repository: serverDetail.Repository,
|
||||
VersionDetail: serverDetail.VersionDetail,
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
|
|
|||
731
internal/api/handlers/v0/publish_integration_test.go
Normal file
731
internal/api/handlers/v0/publish_integration_test.go
Normal file
|
|
@ -0,0 +1,731 @@
|
|||
package v0_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/danielgtaylor/huma/v2/adapters/humago"
|
||||
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
|
||||
"github.com/modelcontextprotocol/registry/internal/model"
|
||||
"github.com/modelcontextprotocol/registry/internal/service"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPublishIntegration(t *testing.T) {
|
||||
// Setup fake service and auth service
|
||||
registryService := service.NewFakeRegistryService()
|
||||
authService := &MockAuthService{}
|
||||
authService.Mock.On("ValidateAuth", mock.Anything, mock.AnythingOfType("model.Authentication")).Return(true, nil)
|
||||
|
||||
// Create a new ServeMux and Huma API
|
||||
mux := http.NewServeMux()
|
||||
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
||||
|
||||
// Register the endpoint
|
||||
v0.RegisterPublishEndpoint(api, registryService, authService)
|
||||
|
||||
t.Run("successful publish with GitHub auth", func(t *testing.T) {
|
||||
publishReq := model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
Name: "io.github.testuser/test-mcp-server",
|
||||
Description: "A test MCP server for integration testing",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/testuser/test-mcp-server",
|
||||
Source: "github",
|
||||
ID: "testuser/test-mcp-server",
|
||||
},
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "1.0.0",
|
||||
},
|
||||
},
|
||||
Packages: []model.Package{
|
||||
{
|
||||
RegistryName: "npm",
|
||||
Name: "test-mcp-server",
|
||||
Version: "1.0.0",
|
||||
RunTimeHint: "node",
|
||||
RuntimeArguments: []model.Argument{
|
||||
{
|
||||
Type: model.ArgumentTypeNamed,
|
||||
Name: "config",
|
||||
InputWithVariables: model.InputWithVariables{
|
||||
Input: model.Input{
|
||||
Description: "Configuration file path",
|
||||
Format: model.FormatFilePath,
|
||||
IsRequired: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Remotes: []model.Remote{
|
||||
{
|
||||
TransportType: "http",
|
||||
URL: "http://localhost:3000/mcp",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Marshal the server detail to JSON
|
||||
jsonData, err := json.Marshal(publishReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a request
|
||||
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer github_test_token_123")
|
||||
|
||||
// Create a response recorder
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
// Call the handler through the mux
|
||||
mux.ServeHTTP(recorder, req)
|
||||
|
||||
// Check the response
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
|
||||
var response model.Server
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEmpty(t, response.ID, "Server ID should be generated")
|
||||
assert.Equal(t, publishReq.Name, response.Name)
|
||||
assert.Equal(t, publishReq.Description, response.Description)
|
||||
|
||||
// Verify the server was actually published by retrieving it
|
||||
publishedServer, err := registryService.GetByID(response.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, publishReq.Name, publishedServer.Name)
|
||||
assert.Equal(t, publishReq.Description, publishedServer.Description)
|
||||
assert.Equal(t, publishReq.VersionDetail.Version, publishedServer.VersionDetail.Version)
|
||||
assert.Len(t, publishedServer.Packages, 1)
|
||||
assert.Len(t, publishedServer.Remotes, 1)
|
||||
})
|
||||
|
||||
t.Run("successful publish without auth (no prefix)", func(t *testing.T) {
|
||||
publishReq := model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
Name: "custom-mcp-server",
|
||||
Description: "A custom MCP server without auth",
|
||||
Repository: model.Repository{
|
||||
URL: "https://example.com/custom-server",
|
||||
Source: "custom",
|
||||
ID: "custom/custom-server",
|
||||
},
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "2.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(publishReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer dummy_token")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
|
||||
var response model.Server
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEmpty(t, response.ID, "Server ID should be generated")
|
||||
})
|
||||
|
||||
t.Run("publish fails with missing name", func(t *testing.T) {
|
||||
publishReq := model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
Name: "", // Missing name
|
||||
Description: "A test server",
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "1.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(publishReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer token")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "Name is required")
|
||||
})
|
||||
|
||||
t.Run("publish fails with missing version", func(t *testing.T) {
|
||||
publishReq := model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
Name: "test-server",
|
||||
Description: "A test server",
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "", // Missing version
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(publishReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer token")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "Version is required")
|
||||
})
|
||||
|
||||
t.Run("publish fails with missing authorization header", func(t *testing.T) {
|
||||
publishReq := model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
Name: "test-server",
|
||||
Description: "A test server",
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "1.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(publishReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// No Authorization header
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "required header parameter is missing")
|
||||
})
|
||||
|
||||
t.Run("publish fails with invalid JSON", func(t *testing.T) {
|
||||
invalidJSON := `{"name": "test", "version": `
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBufferString(invalidJSON))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer token")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "unexpected end of JSON")
|
||||
})
|
||||
|
||||
t.Run("publish fails with unsupported HTTP method", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/v0/publish", nil)
|
||||
req.Header.Set("Authorization", "Bearer token")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "Method Not Allowed")
|
||||
})
|
||||
|
||||
t.Run("publish fails with duplicate name and version", func(t *testing.T) {
|
||||
// First, publish a server successfully
|
||||
firstServerDetail := model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
Name: "io.github.duplicate/test-server",
|
||||
Description: "First server for duplicate test",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/duplicate/test-server",
|
||||
Source: "github",
|
||||
ID: "duplicate/test-server",
|
||||
},
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "1.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(firstServerDetail)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer github_token_first")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code, "First publish should succeed")
|
||||
|
||||
var response model.Server
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
firstServerID := response.ID // Store the ID for later verification
|
||||
|
||||
// Now try to publish another server with the same name and version
|
||||
duplicateServerDetail := model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
Name: "io.github.duplicate/test-server", // Same name
|
||||
Description: "Duplicate server attempt",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/duplicate/test-server-fork",
|
||||
Source: "github",
|
||||
ID: "duplicate/test-server-fork",
|
||||
},
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "1.0.0", // Same version
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
duplicateJSONData, err := json.Marshal(duplicateServerDetail)
|
||||
require.NoError(t, err)
|
||||
|
||||
duplicateReq := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(duplicateJSONData))
|
||||
duplicateReq.Header.Set("Content-Type", "application/json")
|
||||
duplicateReq.Header.Set("Authorization", "Bearer github_token_duplicate")
|
||||
|
||||
duplicateRecorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(duplicateRecorder, duplicateReq)
|
||||
|
||||
// The duplicate should fail
|
||||
assert.Equal(t, http.StatusInternalServerError, duplicateRecorder.Code)
|
||||
assert.Contains(t, duplicateRecorder.Body.String(), "Failed to publish server")
|
||||
|
||||
// Verify that only the first server was actually stored
|
||||
retrievedServer, err := registryService.GetByID(firstServerID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, firstServerDetail.Name, retrievedServer.Name)
|
||||
assert.Equal(t, firstServerDetail.Description, retrievedServer.Description)
|
||||
})
|
||||
|
||||
t.Run("publish succeeds with same name but different version", func(t *testing.T) {
|
||||
// Publish first version
|
||||
firstVersionDetail := model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
Name: "io.github.versioned/test-server",
|
||||
Description: "First version of the server",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/versioned/test-server",
|
||||
Source: "github",
|
||||
ID: "versioned/test-server",
|
||||
},
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "1.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(firstVersionDetail)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer github_token_v1")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code, "First version should succeed")
|
||||
|
||||
var response model.Server
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
firstVersionID := response.ID // Store the ID for later verification
|
||||
require.NotEmpty(t, firstVersionID, "Server ID should be generated")
|
||||
|
||||
// Publish second version with same name but different version
|
||||
secondVersionDetail := model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
Name: "io.github.versioned/test-server", // Same name
|
||||
Description: "Second version of the server",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/versioned/test-server",
|
||||
Source: "github",
|
||||
ID: "versioned/test-server",
|
||||
},
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "2.0.0", // Different version
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
secondJSONData, err := json.Marshal(secondVersionDetail)
|
||||
require.NoError(t, err)
|
||||
|
||||
secondReq := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(secondJSONData))
|
||||
secondReq.Header.Set("Content-Type", "application/json")
|
||||
secondReq.Header.Set("Authorization", "Bearer github_token_v2")
|
||||
|
||||
secondRecorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(secondRecorder, secondReq)
|
||||
|
||||
// The second version should succeed
|
||||
assert.Equal(t, http.StatusOK, secondRecorder.Code)
|
||||
|
||||
var secondResponse model.Server
|
||||
err = json.Unmarshal(secondRecorder.Body.Bytes(), &secondResponse)
|
||||
require.NoError(t, err)
|
||||
secondVersionID := secondResponse.ID // Store the ID for later verification
|
||||
require.NotEmpty(t, secondVersionID, "Server ID for second version should be generated")
|
||||
|
||||
// Verify both versions exist
|
||||
firstRetrieved, err := registryService.GetByID(firstVersionID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "1.0.0", firstRetrieved.VersionDetail.Version)
|
||||
|
||||
secondRetrieved, err := registryService.GetByID(secondVersionID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "2.0.0", secondRetrieved.VersionDetail.Version)
|
||||
})
|
||||
|
||||
t.Run("publish fails when trying to publish older version after newer version", func(t *testing.T) {
|
||||
// First, publish a newer version (2.0.0)
|
||||
newerVersionDetail := model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
Name: "io.github.versioning/version-order-test",
|
||||
Description: "Newer version published first",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/versioning/version-order-test",
|
||||
Source: "github",
|
||||
ID: "versioning/version-order-test",
|
||||
},
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "2.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(newerVersionDetail)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer github_token_newer")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code, "Newer version should be published successfully")
|
||||
|
||||
var response model.Server
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
newerVersionID := response.ID // Store the ID for later verification
|
||||
require.NotEmpty(t, newerVersionID, "Server ID for newer version should be generated")
|
||||
|
||||
// Now try to publish an older version (1.0.0) of the same package
|
||||
olderVersionDetail := model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
Name: "io.github.versioning/version-order-test", // Same name
|
||||
Description: "Older version published after newer",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/versioning/version-order-test",
|
||||
Source: "github",
|
||||
ID: "versioning/version-order-test",
|
||||
},
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "1.0.0", // Older version
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
olderJSONData, err := json.Marshal(olderVersionDetail)
|
||||
require.NoError(t, err)
|
||||
|
||||
olderReq := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(olderJSONData))
|
||||
olderReq.Header.Set("Content-Type", "application/json")
|
||||
olderReq.Header.Set("Authorization", "Bearer github_token_older")
|
||||
|
||||
olderRecorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(olderRecorder, olderReq)
|
||||
|
||||
// This should fail - we shouldn't allow publishing older versions after newer ones
|
||||
assert.Equal(t, http.StatusInternalServerError, olderRecorder.Code, "Publishing older version should fail")
|
||||
assert.Contains(t, olderRecorder.Body.String(), "Failed to publish server", "Error message should mention version")
|
||||
|
||||
// Verify that only the newer version exists
|
||||
newerRetrieved, err := registryService.GetByID(newerVersionID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "2.0.0", newerRetrieved.VersionDetail.Version)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPublishIntegrationEndToEnd(t *testing.T) {
|
||||
registryService := service.NewFakeRegistryService()
|
||||
authService := &MockAuthService{}
|
||||
authService.Mock.On("ValidateAuth", mock.Anything, mock.AnythingOfType("model.Authentication")).Return(true, nil)
|
||||
|
||||
// Create a new ServeMux and Huma API
|
||||
mux := http.NewServeMux()
|
||||
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
||||
|
||||
// Register the endpoint
|
||||
v0.RegisterPublishEndpoint(api, registryService, authService)
|
||||
|
||||
t.Run("end-to-end publish and retrieve flow", func(t *testing.T) {
|
||||
// Step 1: Get initial count of servers
|
||||
initialServers, _, err := registryService.List("", 100)
|
||||
require.NoError(t, err)
|
||||
initialCount := len(initialServers)
|
||||
|
||||
// Step 2: Publish a new server
|
||||
publishReq := model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
Name: "io.github.e2e/end-to-end-server",
|
||||
Description: "End-to-end test server",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/e2e/end-to-end-server",
|
||||
Source: "github",
|
||||
ID: "e2e/end-to-end-server",
|
||||
},
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "1.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(publishReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer github_e2e_token")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, req)
|
||||
|
||||
var response model.Server
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
serverID := response.ID
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
|
||||
// Step 3: Verify the count increased
|
||||
updatedServers, _, err := registryService.List("", 100)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, initialCount+1, len(updatedServers))
|
||||
|
||||
// Step 4: Verify the server can be retrieved by ID
|
||||
retrievedServer, err := registryService.GetByID(serverID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, publishReq.Name, retrievedServer.Name)
|
||||
assert.Equal(t, publishReq.Description, retrievedServer.Description)
|
||||
|
||||
// Step 5: Verify the server appears in the list
|
||||
found := false
|
||||
for _, server := range updatedServers {
|
||||
if server.ID == serverID {
|
||||
found = true
|
||||
assert.Equal(t, publishReq.Name, server.Name)
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Published server should appear in the list")
|
||||
})
|
||||
}
|
||||
|
||||
func TestPublishIntegrationWithComplexPackages(t *testing.T) {
|
||||
registryService := service.NewFakeRegistryService()
|
||||
authService := &MockAuthService{}
|
||||
authService.Mock.On("ValidateAuth", mock.Anything, mock.AnythingOfType("model.Authentication")).Return(true, nil)
|
||||
|
||||
// Create a new ServeMux and Huma API
|
||||
mux := http.NewServeMux()
|
||||
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
||||
|
||||
// Register the endpoint
|
||||
v0.RegisterPublishEndpoint(api, registryService, authService)
|
||||
|
||||
t.Run("publish with complex package configuration", func(t *testing.T) {
|
||||
publishReq := model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
Name: "io.github.complex/advanced-mcp-server",
|
||||
Description: "An advanced MCP server with complex configuration",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/complex/advanced-mcp-server",
|
||||
Source: "github",
|
||||
ID: "complex/advanced-mcp-server",
|
||||
},
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "2.1.0",
|
||||
},
|
||||
},
|
||||
Packages: []model.Package{
|
||||
{
|
||||
RegistryName: "npm",
|
||||
Name: "@example/advanced-mcp-server",
|
||||
Version: "43.1.0",
|
||||
RunTimeHint: "node",
|
||||
RuntimeArguments: []model.Argument{
|
||||
{
|
||||
Type: model.ArgumentTypeNamed,
|
||||
Name: "experimental-modules",
|
||||
},
|
||||
{
|
||||
Type: model.ArgumentTypeNamed,
|
||||
Name: "config",
|
||||
InputWithVariables: model.InputWithVariables{
|
||||
Input: model.Input{
|
||||
Description: "Main configuration file",
|
||||
Format: model.FormatFilePath,
|
||||
IsRequired: true,
|
||||
Default: "./config.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: model.ArgumentTypePositional,
|
||||
Name: "mode",
|
||||
InputWithVariables: model.InputWithVariables{
|
||||
Input: model.Input{
|
||||
Description: "Operation mode",
|
||||
Format: model.FormatString,
|
||||
IsRequired: false,
|
||||
Default: "production",
|
||||
Choices: []string{"development", "staging", "production"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
PackageArguments: []model.Argument{
|
||||
{
|
||||
Type: model.ArgumentTypeNamed,
|
||||
Name: "install-deps",
|
||||
InputWithVariables: model.InputWithVariables{
|
||||
Input: model.Input{
|
||||
Description: "Install dependencies",
|
||||
Format: model.FormatBoolean,
|
||||
Default: "true",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
EnvironmentVariables: []model.KeyValueInput{
|
||||
{
|
||||
Name: "LOG_LEVEL",
|
||||
InputWithVariables: model.InputWithVariables{
|
||||
Input: model.Input{
|
||||
Description: "Logging level",
|
||||
Format: model.FormatString,
|
||||
Default: "info",
|
||||
Choices: []string{"debug", "info", "warn", "error"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "API_KEY",
|
||||
InputWithVariables: model.InputWithVariables{
|
||||
Input: model.Input{
|
||||
Description: "API key for external service",
|
||||
Format: model.FormatString,
|
||||
IsRequired: true,
|
||||
IsSecret: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Remotes: []model.Remote{
|
||||
{
|
||||
TransportType: "http",
|
||||
URL: "http://localhost:8080/mcp",
|
||||
Headers: []model.KeyValueInput{
|
||||
{
|
||||
Name: "API-Version",
|
||||
InputWithVariables: model.InputWithVariables{
|
||||
Input: model.Input{
|
||||
Description: "API Version Header",
|
||||
Format: model.FormatString,
|
||||
Value: "v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(publishReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer github_complex_token")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
|
||||
var response model.Server
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
serverID := response.ID
|
||||
assert.NotEmpty(t, serverID, "Server ID should be generated")
|
||||
|
||||
// Verify the complex server was published correctly
|
||||
publishedServer, err := registryService.GetByID(serverID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify package details
|
||||
require.Len(t, publishedServer.Packages, 1)
|
||||
pkg := publishedServer.Packages[0]
|
||||
assert.Equal(t, "npm", pkg.RegistryName)
|
||||
assert.Equal(t, "@example/advanced-mcp-server", pkg.Name)
|
||||
assert.Len(t, pkg.RuntimeArguments, 3)
|
||||
assert.Len(t, pkg.PackageArguments, 1)
|
||||
assert.Len(t, pkg.EnvironmentVariables, 2)
|
||||
|
||||
// Verify remotes
|
||||
require.Len(t, publishedServer.Remotes, 1)
|
||||
assert.Equal(t, "http", publishedServer.Remotes[0].TransportType)
|
||||
assert.Len(t, publishedServer.Remotes[0].Headers, 1)
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
23
internal/api/handlers/v0/response.go
Normal file
23
internal/api/handlers/v0/response.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package v0
|
||||
|
||||
// Response is a generic wrapper for Huma responses
|
||||
// Usage: Response[HealthBody] instead of HealthOutput
|
||||
type Response[T any] struct {
|
||||
Body T
|
||||
}
|
||||
|
||||
// Example usage:
|
||||
// Instead of:
|
||||
// type HealthOutput struct {
|
||||
// Body HealthBody
|
||||
// }
|
||||
//
|
||||
// You could use:
|
||||
// type HealthOutput = Response[HealthBody]
|
||||
//
|
||||
// Or directly in the handler:
|
||||
// func(...) (*Response[HealthBody], error) {
|
||||
// return &Response[HealthBody]{
|
||||
// Body: HealthBody{...},
|
||||
// }, nil
|
||||
// }
|
||||
|
|
@ -1,22 +1,15 @@
|
|||
// Package v0 contains API handlers for version 0 of the API
|
||||
package v0
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/modelcontextprotocol/registry/internal/model"
|
||||
"github.com/modelcontextprotocol/registry/internal/service"
|
||||
)
|
||||
|
||||
// Response is a paginated API response
|
||||
type PaginatedResponse struct {
|
||||
Data []model.Server `json:"servers"`
|
||||
Metadata Metadata `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// Metadata contains pagination metadata
|
||||
type Metadata struct {
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
|
|
@ -24,107 +17,87 @@ type Metadata struct {
|
|||
Total int `json:"total,omitempty"`
|
||||
}
|
||||
|
||||
// ServersHandler returns a handler for listing registry items
|
||||
func ServersHandler(registry service.RegistryService) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
// ListServersInput represents the input for listing servers
|
||||
type ListServersInput struct {
|
||||
Cursor string `query:"cursor" doc:"Pagination cursor (UUID)" format:"uuid" required:"false"`
|
||||
Limit int `query:"limit" doc:"Number of items per page" default:"30" minimum:"1" maximum:"100"`
|
||||
}
|
||||
|
||||
// Parse cursor and limit from query parameters
|
||||
cursor := r.URL.Query().Get("cursor")
|
||||
if cursor != "" {
|
||||
_, err := uuid.Parse(cursor)
|
||||
// ListServersBody represents the paginated server list response body
|
||||
type ListServersBody struct {
|
||||
Servers []model.Server `json:"servers" doc:"List of MCP servers"`
|
||||
Metadata *Metadata `json:"metadata,omitempty" doc:"Pagination metadata"`
|
||||
}
|
||||
|
||||
// ServerDetailInput represents the input for getting server details
|
||||
type ServerDetailInput struct {
|
||||
ID string `path:"id" doc:"Server ID (UUID)" format:"uuid"`
|
||||
}
|
||||
|
||||
|
||||
// RegisterServersEndpoints registers all server-related endpoints
|
||||
func RegisterServersEndpoints(api huma.API, registry service.RegistryService) {
|
||||
// List servers endpoint
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "list-servers",
|
||||
Method: http.MethodGet,
|
||||
Path: "/v0/servers",
|
||||
Summary: "List MCP servers",
|
||||
Description: "Get a paginated list of MCP servers from the registry",
|
||||
Tags: []string{"servers"},
|
||||
}, func(_ context.Context, input *ListServersInput) (*Response[ListServersBody], error) {
|
||||
// Validate cursor if provided
|
||||
if input.Cursor != "" {
|
||||
_, err := uuid.Parse(input.Cursor)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid cursor parameter", http.StatusBadRequest)
|
||||
return
|
||||
return nil, huma.Error400BadRequest("Invalid cursor parameter")
|
||||
}
|
||||
}
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
|
||||
// Default limit if not specified
|
||||
limit := 30
|
||||
|
||||
// Try to parse limit from query param
|
||||
if limitStr != "" {
|
||||
parsedLimit, err := strconv.Atoi(limitStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid limit parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if limit is within reasonable bounds
|
||||
if parsedLimit <= 0 {
|
||||
http.Error(w, "Limit must be greater than 0", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Cap maximum limit to prevent excessive queries
|
||||
limit = min(parsedLimit, 100)
|
||||
}
|
||||
|
||||
// Use the GetAll method to get paginated results
|
||||
registries, nextCursor, err := registry.List(cursor, limit)
|
||||
// Get paginated results
|
||||
servers, nextCursor, err := registry.List(input.Cursor, input.Limit)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
return nil, huma.Error500InternalServerError("Failed to get registry list", err)
|
||||
}
|
||||
|
||||
// Create paginated response
|
||||
response := PaginatedResponse{
|
||||
Data: registries,
|
||||
// Build response body
|
||||
body := ListServersBody{
|
||||
Servers: servers,
|
||||
}
|
||||
|
||||
|
||||
// Add metadata if there's a next cursor
|
||||
if nextCursor != "" {
|
||||
response.Metadata = Metadata{
|
||||
body.Metadata = &Metadata{
|
||||
NextCursor: nextCursor,
|
||||
Count: len(registries),
|
||||
Count: len(servers),
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ServersDetailHandler returns a handler for getting details of a specific server by ID
|
||||
func ServersDetailHandler(registry service.RegistryService) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract the server ID from the URL path
|
||||
id := r.PathValue("id")
|
||||
|
||||
// Validate that the ID is a valid UUID
|
||||
_, err := uuid.Parse(id)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid server ID format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
return &Response[ListServersBody]{
|
||||
Body: body,
|
||||
}, nil
|
||||
})
|
||||
|
||||
// Get server details endpoint
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "get-server",
|
||||
Method: http.MethodGet,
|
||||
Path: "/v0/servers/{id}",
|
||||
Summary: "Get MCP server details",
|
||||
Description: "Get detailed information about a specific MCP server",
|
||||
Tags: []string{"servers"},
|
||||
}, func(_ context.Context, input *ServerDetailInput) (*Response[model.ServerDetail], error) {
|
||||
// Get the server details from the registry service
|
||||
serverDetail, err := registry.GetByID(id)
|
||||
serverDetail, err := registry.GetByID(input.ID)
|
||||
if err != nil {
|
||||
if err.Error() == "record not found" {
|
||||
http.Error(w, "Server not found", http.StatusNotFound)
|
||||
return
|
||||
return nil, huma.Error404NotFound("Server not found")
|
||||
}
|
||||
http.Error(w, "Error retrieving server details", http.StatusInternalServerError)
|
||||
return
|
||||
return nil, huma.Error500InternalServerError("Failed to get server details", err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(serverDetail); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
return &Response[model.ServerDetail]{
|
||||
Body: *serverDetail,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ import (
|
|||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/danielgtaylor/huma/v2/adapters/humago"
|
||||
"github.com/google/uuid"
|
||||
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
|
||||
"github.com/modelcontextprotocol/registry/internal/model"
|
||||
|
|
@ -15,10 +17,9 @@ import (
|
|||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func TestServersHandler(t *testing.T) {
|
||||
func TestServersListEndpoint(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
method string
|
||||
queryParams string
|
||||
setupMocks func(*MockRegistryService)
|
||||
expectedStatus int
|
||||
|
|
@ -27,8 +28,7 @@ func TestServersHandler(t *testing.T) {
|
|||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "successful list with default parameters",
|
||||
method: http.MethodGet,
|
||||
name: "successful list with default parameters",
|
||||
setupMocks: func(registry *MockRegistryService) {
|
||||
servers := []model.Server{
|
||||
{
|
||||
|
|
@ -100,8 +100,7 @@ func TestServersHandler(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "successful list with cursor and limit",
|
||||
method: http.MethodGet,
|
||||
queryParams: "?cursor=550e8400-e29b-41d4-a716-446655440000" + "&limit=10",
|
||||
queryParams: "?cursor=550e8400-e29b-41d4-a716-446655440000&limit=10",
|
||||
setupMocks: func(registry *MockRegistryService) {
|
||||
servers := []model.Server{
|
||||
{
|
||||
|
|
@ -148,62 +147,46 @@ func TestServersHandler(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "successful list with limit capping at 100",
|
||||
method: http.MethodGet,
|
||||
queryParams: "?limit=150",
|
||||
setupMocks: func(registry *MockRegistryService) {
|
||||
servers := []model.Server{}
|
||||
registry.Mock.On("List", "", 100).Return(servers, "", nil)
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedServers: []model.Server{},
|
||||
setupMocks: func(_ *MockRegistryService) {},
|
||||
expectedStatus: http.StatusUnprocessableEntity, // Huma rejects values > maximum
|
||||
expectedError: "validation failed",
|
||||
},
|
||||
{
|
||||
name: "invalid cursor parameter",
|
||||
method: http.MethodGet,
|
||||
queryParams: "?cursor=invalid-uuid",
|
||||
setupMocks: func(_ *MockRegistryService) {},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectedError: "Invalid cursor parameter",
|
||||
expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors
|
||||
expectedError: "validation failed",
|
||||
},
|
||||
{
|
||||
name: "invalid limit parameter - non-numeric",
|
||||
method: http.MethodGet,
|
||||
queryParams: "?limit=abc",
|
||||
setupMocks: func(_ *MockRegistryService) {},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectedError: "Invalid limit parameter",
|
||||
expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors
|
||||
expectedError: "validation failed",
|
||||
},
|
||||
{
|
||||
name: "invalid limit parameter - zero",
|
||||
method: http.MethodGet,
|
||||
queryParams: "?limit=0",
|
||||
setupMocks: func(_ *MockRegistryService) {},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectedError: "Limit must be greater than 0",
|
||||
expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors
|
||||
expectedError: "validation failed",
|
||||
},
|
||||
{
|
||||
name: "invalid limit parameter - negative",
|
||||
method: http.MethodGet,
|
||||
queryParams: "?limit=-5",
|
||||
setupMocks: func(_ *MockRegistryService) {},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectedError: "Limit must be greater than 0",
|
||||
expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors
|
||||
expectedError: "validation failed",
|
||||
},
|
||||
{
|
||||
name: "registry service error",
|
||||
method: http.MethodGet,
|
||||
name: "registry service error",
|
||||
setupMocks: func(registry *MockRegistryService) {
|
||||
registry.Mock.On("List", "", 30).Return([]model.Server{}, "", errors.New("database connection error"))
|
||||
},
|
||||
expectedStatus: http.StatusInternalServerError,
|
||||
expectedError: "database connection error",
|
||||
},
|
||||
{
|
||||
name: "method not allowed",
|
||||
method: http.MethodPost,
|
||||
setupMocks: func(_ *MockRegistryService) {},
|
||||
expectedStatus: http.StatusMethodNotAllowed,
|
||||
expectedError: "Method not allowed",
|
||||
expectedError: "Failed to get registry list",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -213,36 +196,40 @@ func TestServersHandler(t *testing.T) {
|
|||
mockRegistry := new(MockRegistryService)
|
||||
tc.setupMocks(mockRegistry)
|
||||
|
||||
// Create handler
|
||||
handler := v0.ServersHandler(mockRegistry)
|
||||
// Create a new test API
|
||||
mux := http.NewServeMux()
|
||||
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
||||
|
||||
// Register the servers endpoints
|
||||
v0.RegisterServersEndpoints(api, mockRegistry)
|
||||
|
||||
// Create request
|
||||
url := "/v0/servers" + tc.queryParams
|
||||
req, err := http.NewRequestWithContext(context.Background(), tc.method, url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Create response recorder
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
// Call the handler
|
||||
handler.ServeHTTP(rr, req)
|
||||
// Serve the request
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
// Check status code
|
||||
assert.Equal(t, tc.expectedStatus, rr.Code)
|
||||
assert.Equal(t, tc.expectedStatus, w.Code)
|
||||
|
||||
if tc.expectedStatus == http.StatusOK {
|
||||
// Check content type
|
||||
assert.Equal(t, "application/json", rr.Header().Get("Content-Type"))
|
||||
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
|
||||
|
||||
// Parse response body
|
||||
var resp v0.PaginatedResponse
|
||||
err = json.NewDecoder(rr.Body).Decode(&resp)
|
||||
var resp struct {
|
||||
Servers []model.Server `json:"servers"`
|
||||
Metadata *v0.Metadata `json:"metadata,omitempty"`
|
||||
}
|
||||
err := json.NewDecoder(w.Body).Decode(&resp)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Check the response data
|
||||
assert.Equal(t, tc.expectedServers, resp.Data)
|
||||
if tc.expectedServers != nil {
|
||||
assert.Equal(t, tc.expectedServers, resp.Servers)
|
||||
}
|
||||
|
||||
// Check metadata if expected
|
||||
if tc.expectedMeta != nil {
|
||||
|
|
@ -253,7 +240,7 @@ func TestServersHandler(t *testing.T) {
|
|||
}
|
||||
} else if tc.expectedError != "" {
|
||||
// Check error message for non-200 responses
|
||||
assert.Contains(t, rr.Body.String(), tc.expectedError)
|
||||
assert.Contains(t, w.Body.String(), tc.expectedError)
|
||||
}
|
||||
|
||||
// Verify mock expectations
|
||||
|
|
@ -262,14 +249,124 @@ func TestServersHandler(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestServersHandlerIntegration tests the servers list handler with actual HTTP requests
|
||||
func TestServersHandlerIntegration(t *testing.T) {
|
||||
func TestServersDetailEndpoint(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
serverID string
|
||||
setupMocks func(*MockRegistryService, string)
|
||||
expectedStatus int
|
||||
expectedServer *model.ServerDetail
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "successful get server detail",
|
||||
serverID: uuid.New().String(),
|
||||
setupMocks: func(registry *MockRegistryService, serverID string) {
|
||||
serverDetail := &model.ServerDetail{
|
||||
Server: model.Server{
|
||||
ID: serverID,
|
||||
Name: "test-server-detail",
|
||||
Description: "Test server detail",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/example/test-server-detail",
|
||||
Source: "github",
|
||||
ID: "example/test-server-detail",
|
||||
},
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "2.0.0",
|
||||
ReleaseDate: "2025-05-27T12:00:00Z",
|
||||
IsLatest: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
registry.Mock.On("GetByID", serverID).Return(serverDetail, nil)
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "invalid server ID format",
|
||||
serverID: "invalid-uuid",
|
||||
setupMocks: func(_ *MockRegistryService, _ string) {},
|
||||
expectedStatus: http.StatusUnprocessableEntity,
|
||||
expectedError: "validation failed",
|
||||
},
|
||||
{
|
||||
name: "server not found",
|
||||
serverID: uuid.New().String(),
|
||||
setupMocks: func(registry *MockRegistryService, serverID string) {
|
||||
registry.Mock.On("GetByID", serverID).Return(nil, errors.New("record not found"))
|
||||
},
|
||||
expectedStatus: http.StatusNotFound,
|
||||
expectedError: "Server not found",
|
||||
},
|
||||
{
|
||||
name: "registry service error",
|
||||
serverID: uuid.New().String(),
|
||||
setupMocks: func(registry *MockRegistryService, serverID string) {
|
||||
registry.Mock.On("GetByID", serverID).Return(nil, errors.New("database connection error"))
|
||||
},
|
||||
expectedStatus: http.StatusInternalServerError,
|
||||
expectedError: "Failed to get server details",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Create mock registry service
|
||||
mockRegistry := new(MockRegistryService)
|
||||
tc.setupMocks(mockRegistry, tc.serverID)
|
||||
|
||||
// Create a new test API
|
||||
mux := http.NewServeMux()
|
||||
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
||||
|
||||
// Register the servers endpoints
|
||||
v0.RegisterServersEndpoints(api, mockRegistry)
|
||||
|
||||
// Create request
|
||||
url := "/v0/servers/" + tc.serverID
|
||||
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Serve the request
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
// Check status code
|
||||
assert.Equal(t, tc.expectedStatus, w.Code)
|
||||
|
||||
if tc.expectedStatus == http.StatusOK {
|
||||
// Check content type
|
||||
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
|
||||
|
||||
// Parse response body
|
||||
var serverDetailResp model.ServerDetail
|
||||
err := json.NewDecoder(w.Body).Decode(&serverDetailResp)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Check that we got a valid response
|
||||
assert.NotEmpty(t, serverDetailResp.ID)
|
||||
assert.NotEmpty(t, serverDetailResp.Name)
|
||||
} else if tc.expectedError != "" {
|
||||
// Check error message for non-200 responses
|
||||
assert.Contains(t, w.Body.String(), tc.expectedError)
|
||||
}
|
||||
|
||||
// Verify mock expectations
|
||||
mockRegistry.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestServersEndpointsIntegration tests the servers endpoints with actual HTTP requests
|
||||
func TestServersEndpointsIntegration(t *testing.T) {
|
||||
// Create mock registry service
|
||||
mockRegistry := new(MockRegistryService)
|
||||
|
||||
// Test data
|
||||
serverID := uuid.New().String()
|
||||
servers := []model.Server{
|
||||
{
|
||||
ID: "550e8400-e29b-41d4-a716-446655440004",
|
||||
ID: serverID,
|
||||
Name: "integration-test-server",
|
||||
Description: "Integration test server",
|
||||
Repository: model.Repository{
|
||||
|
|
@ -285,107 +382,87 @@ func TestServersHandlerIntegration(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
mockRegistry.Mock.On("List", "", 30).Return(servers, "", nil)
|
||||
|
||||
// Create test server
|
||||
server := httptest.NewServer(v0.ServersHandler(mockRegistry))
|
||||
defer server.Close()
|
||||
|
||||
// Send request to the test server
|
||||
ctx := context.Background()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check status code
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Check content type
|
||||
assert.Equal(t, "application/json", resp.Header.Get("Content-Type"))
|
||||
|
||||
// Parse response body
|
||||
var paginatedResp v0.PaginatedResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&paginatedResp)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Check the response data
|
||||
assert.Equal(t, servers, paginatedResp.Data)
|
||||
assert.Empty(t, paginatedResp.Metadata.NextCursor)
|
||||
|
||||
// Verify mock expectations
|
||||
mockRegistry.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// TestServersDetailHandlerIntegration tests the servers detail handler with actual HTTP requests
|
||||
func TestServersDetailHandlerIntegration(t *testing.T) {
|
||||
serverID := uuid.New().String()
|
||||
|
||||
// Create mock registry service
|
||||
mockRegistry := new(MockRegistryService)
|
||||
|
||||
serverDetail := &model.ServerDetail{
|
||||
Server: model.Server{
|
||||
ID: serverID,
|
||||
Name: "integration-test-server-detail",
|
||||
Description: "Integration test server detail",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/example/integration-test-detail",
|
||||
Source: "github",
|
||||
ID: "example/integration-test-detail",
|
||||
},
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "2.0.0",
|
||||
ReleaseDate: "2025-05-27T12:00:00Z",
|
||||
IsLatest: true,
|
||||
},
|
||||
},
|
||||
Server: servers[0],
|
||||
}
|
||||
|
||||
// Setup mocks
|
||||
mockRegistry.Mock.On("List", "", 30).Return(servers, "", nil)
|
||||
mockRegistry.Mock.On("GetByID", serverID).Return(serverDetail, nil)
|
||||
|
||||
// Create a new test API
|
||||
mux := http.NewServeMux()
|
||||
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
||||
|
||||
// Register the servers endpoints
|
||||
v0.RegisterServersEndpoints(api, mockRegistry)
|
||||
|
||||
// Create test server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r.SetPathValue("id", serverID)
|
||||
v0.ServersDetailHandler(mockRegistry).ServeHTTP(w, r)
|
||||
}))
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
// Send request to the test server
|
||||
ctx := context.Background()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
// Test list endpoint
|
||||
t.Run("list servers integration", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/v0/servers", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// Check status code
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Check status code
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
// Check content type
|
||||
assert.Equal(t, "application/json", resp.Header.Get("Content-Type"))
|
||||
|
||||
// Check content type
|
||||
assert.Equal(t, "application/json", resp.Header.Get("Content-Type"))
|
||||
// Parse response body
|
||||
var listResp struct {
|
||||
Servers []model.Server `json:"servers"`
|
||||
}
|
||||
err = json.NewDecoder(resp.Body).Decode(&listResp)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Parse response body
|
||||
var serverDetailResp model.ServerDetail
|
||||
err = json.NewDecoder(resp.Body).Decode(&serverDetailResp)
|
||||
assert.NoError(t, err)
|
||||
// Check the response data
|
||||
assert.Equal(t, servers, listResp.Servers)
|
||||
})
|
||||
|
||||
// Check the response data
|
||||
assert.Equal(t, *serverDetail, serverDetailResp)
|
||||
// Test get server detail endpoint
|
||||
t.Run("get server detail integration", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/v0/servers/"+serverID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check status code
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Check content type
|
||||
assert.Equal(t, "application/json", resp.Header.Get("Content-Type"))
|
||||
|
||||
// Parse response body
|
||||
var serverDetailResp model.ServerDetail
|
||||
err = json.NewDecoder(resp.Body).Decode(&serverDetailResp)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Check the response data
|
||||
assert.Equal(t, *serverDetail, serverDetailResp)
|
||||
})
|
||||
|
||||
// Verify mock expectations
|
||||
mockRegistry.AssertExpectations(t)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
// Package v0 contains API handlers for version 0 of the API
|
||||
package v0
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "github.com/swaggo/files" // Swagger files needed for embedding
|
||||
httpSwagger "github.com/swaggo/http-swagger"
|
||||
)
|
||||
|
||||
// SwaggerHandler returns a handler that serves the Swagger UI
|
||||
func SwaggerHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// When accessed directly, redirect to the UI path
|
||||
if r.URL.Path == "/v0/swagger" {
|
||||
http.Redirect(w, r, "/v0/swagger/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Serve the Swagger UI
|
||||
handler := httpSwagger.Handler(
|
||||
httpSwagger.URL("/v0/swagger/doc.json"), // The URL to the generated Swagger JSON
|
||||
httpSwagger.DeepLinking(true),
|
||||
)
|
||||
|
||||
// Handle other Swagger UI paths
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// SwaggerJSONHandler serves the Swagger specification as JSON
|
||||
func SwaggerJSONHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Find the project root directory
|
||||
workDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
http.Error(w, "Unable to determine working directory", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Path to the swagger YAML file
|
||||
swaggerFilePath := filepath.Join(workDir, "internal", "docs", "swagger.yaml")
|
||||
|
||||
// Serve the file directly with the correct content type
|
||||
http.ServeFile(w, r, swaggerFilePath)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue