Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions internal/server/controllers/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,17 @@ func ListPlugins(c *gin.Context) {
})
}

func ListPluginsByCategory(c *gin.Context) {
BindRequest(c, func(request struct {
TenantID string `uri:"tenant_id" validate:"required"`
Category plugin_entities.PluginCategory `uri:"category" validate:"required"`
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Instead of validating the category manually in the service layer, you can leverage the oneof validator tag in the binding struct. This ensures that invalid categories are rejected immediately at the controller level.

Category plugin_entities.PluginCategory `uri:"category" validate:"required,oneof=tool model extension agent-strategy datasource trigger"`

Page int `form:"page" validate:"required,min=1"`
PageSize int `form:"page_size" validate:"required,min=1,max=256"`
}) {
c.JSON(http.StatusOK, service.ListPluginsByCategory(request.TenantID, request.Category, request.Page, request.PageSize))
})
}

func BatchFetchPluginInstallationByIDs(c *gin.Context) {
BindRequest(c, func(request struct {
TenantID string `uri:"tenant_id" validate:"required"`
Expand Down
1 change: 1 addition & 0 deletions internal/server/http_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ func (app *App) pluginManagementGroup(group *gin.RouterGroup, config *app.Config
group.GET("/fetch/readme", controllers.FetchPluginReadme)
group.POST("/uninstall", controllers.UninstallPlugin)
group.GET("/list", controllers.ListPlugins)
group.GET("/:category/list", controllers.ListPluginsByCategory)
group.POST("/installation/fetch/batch", controllers.BatchFetchPluginInstallationByIDs)
group.POST("/installation/missing", controllers.FetchMissingPluginInstallations)
group.GET("/models", controllers.ListModels)
Expand Down
7 changes: 5 additions & 2 deletions internal/service/install_plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ import (
)

type InstallPluginResponse struct {
AllInstalled bool `json:"all_installed"`
TaskID string `json:"task_id"`
AllInstalled bool `json:"all_installed"`
TaskID string `json:"task_id"`
Task *models.InstallTask `json:"task,omitempty"`
}

// Dify supports install multiple plugins to a tenant at once
Expand Down Expand Up @@ -142,6 +143,7 @@ func InstallMultiplePluginsToTenant(
// EE edition reference task should not be the first one
// here we use `PrimaryID` to present the user-facing task id
TaskID: taskRegistry.PrimaryID(),
Task: taskRegistry.PrimaryTask(),
})
}

Expand Down Expand Up @@ -318,6 +320,7 @@ func UpgradePlugin(
return entities.NewSuccessResponse(&InstallPluginResponse{
AllInstalled: false,
TaskID: taskRegistry.PrimaryID(),
Task: taskRegistry.PrimaryTask(),
})
}

Expand Down
74 changes: 61 additions & 13 deletions internal/service/install_plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,57 @@ import (
"testing"

"github.com/langgenius/dify-plugin-daemon/internal/types/app"
"github.com/langgenius/dify-plugin-daemon/internal/types/models"
"github.com/langgenius/dify-plugin-daemon/pkg/entities/plugin_entities"
)

func TestInstallPluginResponseIncludesPrimaryTask(t *testing.T) {
setupTestDB(t)

identifier, err := plugin_entities.NewPluginUniqueIdentifier("author/test-plugin:1.0.0@abcdef1234567890abcdef1234567890ab")
if err != nil {
t.Fatalf("failed to create plugin unique identifier: %v", err)
}

taskRegistry, err := createInstallTasks([]string{"tenant-123"}, []models.InstallTaskPluginStatus{
{
PluginUniqueIdentifier: identifier,
PluginID: identifier.PluginID(),
Status: models.InstallTaskStatusPending,
Source: "marketplace",
},
})
if err != nil {
t.Fatalf("failed to create install task: %v", err)
}

response := InstallPluginResponse{
AllInstalled: false,
TaskID: taskRegistry.PrimaryID(),
Task: taskRegistry.PrimaryTask(),
}

data, err := json.Marshal(response)
if err != nil {
t.Fatalf("failed to marshal response: %v", err)
}

var decoded InstallPluginResponse
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}

if decoded.Task == nil {
t.Fatal("expected response task to be present")
}
if decoded.TaskID == "" {
t.Fatal("expected response task_id to be present")
}
if decoded.Task.ID != decoded.TaskID {
t.Fatalf("task id mismatch: got %s, want %s", decoded.Task.ID, decoded.TaskID)
}
}

func TestUpgradePlugin(t *testing.T) {
originalIdentifier, err := plugin_entities.NewPluginUniqueIdentifier("author/test-plugin:1.0.0@abcdef1234567890abcdef1234567890ab")
if err != nil {
Expand All @@ -24,32 +72,32 @@ func TestUpgradePlugin(t *testing.T) {
}

tests := []struct {
name string
tenantId string
source string
meta map[string]any
originalPluginUniqueIdentifier plugin_entities.PluginUniqueIdentifier
newPluginUniqueIdentifier plugin_entities.PluginUniqueIdentifier
wantSuccess bool
wantAllInstalled bool
wantTaskIDEmpty bool
name string
tenantId string
source string
meta map[string]any
originalPluginUniqueIdentifier plugin_entities.PluginUniqueIdentifier
newPluginUniqueIdentifier plugin_entities.PluginUniqueIdentifier
wantSuccess bool
wantAllInstalled bool
wantTaskIDEmpty bool
}{
{
name: "same plugin identifiers",
tenantId: "tenant-123",
source: "test",
meta: map[string]any{},
originalPluginUniqueIdentifier: originalIdentifier,
newPluginUniqueIdentifier: originalIdentifier,
originalPluginUniqueIdentifier: originalIdentifier,
newPluginUniqueIdentifier: originalIdentifier,
wantSuccess: false,
},
{
name: "different plugin identifiers",
tenantId: "tenant-123",
source: "test",
meta: map[string]any{},
originalPluginUniqueIdentifier: originalIdentifier,
newPluginUniqueIdentifier: newIdentifier,
originalPluginUniqueIdentifier: originalIdentifier,
newPluginUniqueIdentifier: newIdentifier,
wantSuccess: false,
},
}
Expand Down
129 changes: 129 additions & 0 deletions internal/service/manage_plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,46 @@ import (
"github.com/langgenius/dify-plugin-daemon/pkg/utils/strings"
)

const pluginCategoryListScanPageSize = 256

type pluginInstallationResponse struct {
ID string `json:"id"`
Name string `json:"name"`
PluginID string `json:"plugin_id"`
TenantID string `json:"tenant_id"`
PluginUniqueIdentifier string `json:"plugin_unique_identifier"`
EndpointsActive int `json:"endpoints_active"`
EndpointsSetups int `json:"endpoints_setups"`
InstallationID string `json:"installation_id"`
Declaration *plugin_entities.PluginDeclaration `json:"declaration"`
RuntimeType plugin_entities.PluginRuntimeType `json:"runtime_type"`
Version manifest_entities.Version `json:"version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Source string `json:"source"`
Checksum string `json:"checksum"`
Meta map[string]any `json:"meta"`
}
Comment on lines +20 to +37
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The pluginInstallationResponse struct is identical to the installation struct defined locally inside ListPlugins (line 59). To improve maintainability and eliminate code duplication, consider refactoring ListPlugins to reuse this package-level struct.


type pluginListResponse struct {
List []pluginInstallationResponse `json:"list"`
HasMore bool `json:"has_more"`
}

func isValidPluginCategory(category plugin_entities.PluginCategory) bool {
switch category {
case plugin_entities.PLUGIN_CATEGORY_TOOL,
plugin_entities.PLUGIN_CATEGORY_MODEL,
plugin_entities.PLUGIN_CATEGORY_EXTENSION,
plugin_entities.PLUGIN_CATEGORY_AGENT_STRATEGY,
plugin_entities.PLUGIN_CATEGORY_DATASOURCE,
plugin_entities.PLUGIN_CATEGORY_TRIGGER:
return true
default:
return false
}
}

func ListPlugins(tenant_id string, page int, page_size int) *entities.Response {
type installation struct {
ID string `json:"id"`
Expand Down Expand Up @@ -105,6 +145,95 @@ func ListPlugins(tenant_id string, page int, page_size int) *entities.Response {
return entities.NewSuccessResponse(finalData)
}

func ListPluginsByCategory(
tenant_id string,
category plugin_entities.PluginCategory,
page int,
page_size int,
) *entities.Response {
if !isValidPluginCategory(category) {
return exception.BadRequestError(errors.New("invalid plugin category")).ToResponse()
}

skippedMatches := (page - 1) * page_size
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If page is less than 1, skippedMatches can result in a negative value. Although the controller validates page >= 1, it is safer to guard against this in the service layer to prevent potential negative index issues if called from other contexts.

Suggested change
skippedMatches := (page - 1) * page_size
skippedMatches := 0
if page > 1 {
skippedMatches = (page - 1) * page_size
}

targetMatches := page_size + 1
data := make([]pluginInstallationResponse, 0, targetMatches)

for scanPage := 1; len(data) < targetMatches; scanPage++ {
pluginInstallations, err := db.GetAll[models.PluginInstallation](
db.Equal("tenant_id", tenant_id),
db.OrderBy("created_at", true),
db.Page(scanPage, pluginCategoryListScanPageSize),
)
if err != nil {
return exception.InternalServerError(err).ToResponse()
}

if len(pluginInstallations) == 0 {
break
}

for _, plugin_installation := range pluginInstallations {
pluginUniqueIdentifier, err := plugin_entities.NewPluginUniqueIdentifier(
plugin_installation.PluginUniqueIdentifier,
)
if err != nil {
return exception.UniqueIdentifierError(err).ToResponse()
}

pluginDeclaration, err := helper.CombinedGetPluginDeclaration(
pluginUniqueIdentifier,
plugin_entities.PluginRuntimeType(plugin_installation.RuntimeType),
)
if err != nil {
return exception.InternalServerError(err).ToResponse()
}
Comment on lines +188 to +190
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If helper.CombinedGetPluginDeclaration fails for any single plugin installation (e.g., due to corrupted files or missing cache), the entire ListPluginsByCategory API will fail with a 500 Internal Server Error. To make the API more robust, consider skipping the corrupted plugin so that the rest of the tenant's plugins can still be loaded successfully.

Suggested change
if err != nil {
return exception.InternalServerError(err).ToResponse()
}
if err != nil {
continue
}


if pluginDeclaration.Category() != category {
continue
}

if skippedMatches > 0 {
skippedMatches--
continue
}

data = append(data, pluginInstallationResponse{
ID: plugin_installation.ID,
Name: pluginDeclaration.Name,
TenantID: plugin_installation.TenantID,
PluginID: pluginUniqueIdentifier.PluginID(),
PluginUniqueIdentifier: pluginUniqueIdentifier.String(),
InstallationID: plugin_installation.ID,
Declaration: pluginDeclaration,
EndpointsSetups: plugin_installation.EndpointsSetups,
EndpointsActive: plugin_installation.EndpointsActive,
RuntimeType: plugin_entities.PluginRuntimeType(plugin_installation.RuntimeType),
Version: pluginDeclaration.Version,
CreatedAt: plugin_installation.CreatedAt,
UpdatedAt: plugin_installation.UpdatedAt,
Source: plugin_installation.Source,
Meta: plugin_installation.Meta,
Checksum: pluginUniqueIdentifier.Checksum(),
})
if len(data) == targetMatches {
break
}
}

if len(pluginInstallations) < pluginCategoryListScanPageSize {
break
}
}

hasMore := len(data) > page_size
if hasMore {
data = data[:page_size]
}

return entities.NewSuccessResponse(pluginListResponse{List: data, HasMore: hasMore})
}

// Using plugin_ids to fetch plugin installations
func BatchFetchPluginInstallationByIDs(tenant_id string, plugin_ids []string) *entities.Response {
type installation struct {
Expand Down
14 changes: 11 additions & 3 deletions internal/tasks/install_plugin_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,21 @@ func (r *InstallTaskRegistry) IDs() []string {
}

func (r *InstallTaskRegistry) PrimaryID() string {
if len(r.Order) == 0 {
task := r.PrimaryTask()
if task == nil {
return ""
}
return task.ID
}

func (r *InstallTaskRegistry) PrimaryTask() *models.InstallTask {
if len(r.Order) == 0 {
return nil
}
if task, ok := r.Tasks[r.Order[0]]; ok {
return task.ID
return task
}
return ""
return nil
}

func truncateMessage(message string) string {
Expand Down
Loading