diff --git a/cmd/arduino-app-cli/brick/bricks.go b/cmd/arduino-app-cli/brick/bricks.go index 692552cc..74dd3a3a 100644 --- a/cmd/arduino-app-cli/brick/bricks.go +++ b/cmd/arduino-app-cli/brick/bricks.go @@ -17,16 +17,18 @@ package brick import ( "github.com/spf13/cobra" + + "github.com/arduino/arduino-app-cli/internal/orchestrator/config" ) -func NewBrickCmd() *cobra.Command { +func NewBrickCmd(cfg config.Configuration) *cobra.Command { appCmd := &cobra.Command{ Use: "brick", Short: "Manage Arduino Bricks", } appCmd.AddCommand(newBricksListCmd()) - appCmd.AddCommand(newBricksDetailsCmd()) + appCmd.AddCommand(newBricksDetailsCmd(cfg)) return appCmd } diff --git a/cmd/arduino-app-cli/brick/details.go b/cmd/arduino-app-cli/brick/details.go index fe025078..a1ba72f1 100644 --- a/cmd/arduino-app-cli/brick/details.go +++ b/cmd/arduino-app-cli/brick/details.go @@ -25,21 +25,23 @@ import ( "github.com/arduino/arduino-app-cli/cmd/arduino-app-cli/internal/servicelocator" "github.com/arduino/arduino-app-cli/cmd/feedback" "github.com/arduino/arduino-app-cli/internal/orchestrator/bricks" + "github.com/arduino/arduino-app-cli/internal/orchestrator/config" ) -func newBricksDetailsCmd() *cobra.Command { +func newBricksDetailsCmd(cfg config.Configuration) *cobra.Command { return &cobra.Command{ Use: "details", Short: "Details of a specific brick", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - bricksDetailsHandler(args[0]) + bricksDetailsHandler(args[0], cfg) }, } } -func bricksDetailsHandler(id string) { - res, err := servicelocator.GetBrickService().BricksDetails(id) +func bricksDetailsHandler(id string, cfg config.Configuration) { + res, err := servicelocator.GetBrickService().BricksDetails(id, servicelocator.GetAppIDProvider(), + cfg) if err != nil { if errors.Is(err, bricks.ErrBrickNotFound) { feedback.Fatal(err.Error(), feedback.ErrBadArgument) diff --git a/cmd/arduino-app-cli/main.go b/cmd/arduino-app-cli/main.go index 765ccd69..e859aae2 100644 --- a/cmd/arduino-app-cli/main.go +++ b/cmd/arduino-app-cli/main.go @@ -71,7 +71,7 @@ func run(configuration cfg.Configuration) error { rootCmd.AddCommand( app.NewAppCmd(configuration), - brick.NewBrickCmd(), + brick.NewBrickCmd(configuration), completion.NewCompletionCommand(), daemon.NewDaemonCmd(configuration, Version), properties.NewPropertiesCmd(configuration), diff --git a/internal/api/api.go b/internal/api/api.go index 1d825317..08d31d84 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -56,7 +56,7 @@ func NewHTTPRouter( mux.Handle("GET /v1/version", handlers.HandlerVersion(version)) mux.Handle("GET /v1/config", handlers.HandleConfig(cfg)) mux.Handle("GET /v1/bricks", handlers.HandleBrickList(brickService)) - mux.Handle("GET /v1/bricks/{brickID}", handlers.HandleBrickDetails(brickService)) + mux.Handle("GET /v1/bricks/{brickID}", handlers.HandleBrickDetails(brickService, idProvider, cfg)) mux.Handle("GET /v1/properties", handlers.HandlePropertyKeys(cfg)) mux.Handle("GET /v1/properties/{key}", handlers.HandlePropertyGet(cfg)) diff --git a/internal/api/handlers/bricks.go b/internal/api/handlers/bricks.go index d21c6b3a..7e95753a 100644 --- a/internal/api/handlers/bricks.go +++ b/internal/api/handlers/bricks.go @@ -26,6 +26,7 @@ import ( "github.com/arduino/arduino-app-cli/internal/api/models" "github.com/arduino/arduino-app-cli/internal/orchestrator/app" "github.com/arduino/arduino-app-cli/internal/orchestrator/bricks" + "github.com/arduino/arduino-app-cli/internal/orchestrator/config" "github.com/arduino/arduino-app-cli/internal/render" ) @@ -153,14 +154,15 @@ func HandleBrickCreate( } } -func HandleBrickDetails(brickService *bricks.Service) http.HandlerFunc { +func HandleBrickDetails(brickService *bricks.Service, idProvider *app.IDProvider, + cfg config.Configuration) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := r.PathValue("brickID") if id == "" { render.EncodeResponse(w, http.StatusBadRequest, models.ErrorResponse{Details: "id must be set"}) return } - res, err := brickService.BricksDetails(id) + res, err := brickService.BricksDetails(id, idProvider, cfg) if err != nil { if errors.Is(err, bricks.ErrBrickNotFound) { details := fmt.Sprintf("brick with id %q not found", id) diff --git a/internal/e2e/daemon/brick_test.go b/internal/e2e/daemon/brick_test.go index ab04859f..fa1cab40 100644 --- a/internal/e2e/daemon/brick_test.go +++ b/internal/e2e/daemon/brick_test.go @@ -24,13 +24,44 @@ import ( "github.com/arduino/go-paths-helper" "github.com/stretchr/testify/require" + "go.bug.st/f" "github.com/arduino/arduino-app-cli/internal/api/models" + "github.com/arduino/arduino-app-cli/internal/e2e/client" "github.com/arduino/arduino-app-cli/internal/orchestrator/bricksindex" "github.com/arduino/arduino-app-cli/internal/orchestrator/config" "github.com/arduino/arduino-app-cli/internal/store" ) +func setupTestBrick(t *testing.T) (*client.CreateAppResp, *client.ClientWithResponses) { + httpClient := GetHttpclient(t) + createResp, err := httpClient.CreateAppWithResponse( + t.Context(), + &client.CreateAppParams{SkipSketch: f.Ptr(true)}, + client.CreateAppRequest{ + Icon: f.Ptr("💻"), + Name: "test-app", + Description: f.Ptr("My app description"), + }, + func(ctx context.Context, req *http.Request) error { return nil }, + ) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, createResp.StatusCode()) + require.NotNil(t, createResp.JSON201) + + resp, err := httpClient.UpsertAppBrickInstanceWithResponse( + t.Context(), + *createResp.JSON201.Id, + ImageClassifactionBrickID, + client.BrickCreateUpdateRequest{Model: f.Ptr("mobilenet-image-classification")}, + func(ctx context.Context, req *http.Request) error { return nil }, + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode()) + + return createResp, httpClient +} + func TestBricksList(t *testing.T) { httpClient := GetHttpclient(t) @@ -56,8 +87,8 @@ func TestBricksList(t *testing.T) { } func TestBricksDetails(t *testing.T) { + _, httpClient := setupTestBrick(t) - httpClient := GetHttpclient(t) t.Run("should return 404 Not Found for an invalid brick ID", func(t *testing.T) { invalidBrickID := "notvalidBrickId" var actualBody models.ErrorResponse @@ -76,6 +107,14 @@ func TestBricksDetails(t *testing.T) { t.Run("should return 200 OK with full details for a valid brick ID", func(t *testing.T) { validBrickID := "arduino:image_classification" + expectedUsedByApps := []client.AppReference{ + { + Id: f.Ptr("dXNlcjp0ZXN0LWFwcA"), + Name: f.Ptr("test-app"), + Icon: f.Ptr("💻"), + }, + } + response, err := httpClient.GetBrickDetailsWithResponse(t.Context(), validBrickID, func(ctx context.Context, req *http.Request) error { return nil }) require.NoError(t, err) require.Equal(t, http.StatusOK, response.StatusCode(), "status code should be 200 ok") @@ -92,6 +131,7 @@ func TestBricksDetails(t *testing.T) { require.Equal(t, "path to the model file", *(*response.JSON200.Variables)["EI_CLASSIFICATION_MODEL"].Description) require.Equal(t, false, *(*response.JSON200.Variables)["EI_CLASSIFICATION_MODEL"].Required) require.NotEmpty(t, *response.JSON200.Readme) - require.Nil(t, response.JSON200.UsedByApps) + require.NotNil(t, response.JSON200.UsedByApps, "UsedByApps should not be nil") + require.Equal(t, expectedUsedByApps, *(response.JSON200.UsedByApps)) }) } diff --git a/internal/orchestrator/bricks/bricks.go b/internal/orchestrator/bricks/bricks.go index 4e08b2c2..759b5cc6 100644 --- a/internal/orchestrator/bricks/bricks.go +++ b/internal/orchestrator/bricks/bricks.go @@ -18,6 +18,7 @@ package bricks import ( "errors" "fmt" + "log/slog" "maps" "slices" @@ -26,6 +27,7 @@ import ( "github.com/arduino/arduino-app-cli/internal/orchestrator/app" "github.com/arduino/arduino-app-cli/internal/orchestrator/bricksindex" + "github.com/arduino/arduino-app-cli/internal/orchestrator/config" "github.com/arduino/arduino-app-cli/internal/orchestrator/modelsindex" "github.com/arduino/arduino-app-cli/internal/store" ) @@ -125,7 +127,8 @@ func (s *Service) AppBrickInstanceDetails(a *app.ArduinoApp, brickID string) (Br }, nil } -func (s *Service) BricksDetails(id string) (BrickDetailsResult, error) { +func (s *Service) BricksDetails(id string, idProvider *app.IDProvider, + cfg config.Configuration) (BrickDetailsResult, error) { brick, found := s.bricksIndex.FindBrickByID(id) if !found { return BrickDetailsResult{}, ErrBrickNotFound @@ -160,6 +163,11 @@ func (s *Service) BricksDetails(id string) (BrickDetailsResult, error) { } }) + usedByApps, err := getUsedByApps(cfg, brick.ID, idProvider) + if err != nil { + return BrickDetailsResult{}, fmt.Errorf("unable to get used by apps: %w", err) + } + return BrickDetailsResult{ ID: id, Name: brick.Name, @@ -171,9 +179,63 @@ func (s *Service) BricksDetails(id string) (BrickDetailsResult, error) { Readme: readme, ApiDocsPath: apiDocsPath, CodeExamples: codeExamples, + UsedByApps: usedByApps, }, nil } +func getUsedByApps( + cfg config.Configuration, brickId string, idProvider *app.IDProvider) ([]AppReference, error) { + var ( + pathsToExplore paths.PathList + appPaths paths.PathList + ) + pathsToExplore.Add(cfg.ExamplesDir()) + pathsToExplore.Add(cfg.AppsDir()) + usedByApps := []AppReference{} + + for _, p := range pathsToExplore { + res, err := p.ReadDirRecursiveFiltered(func(file *paths.Path) bool { + if file.Base() == ".cache" { + return false + } + if file.Join("app.yaml").NotExist() && file.Join("app.yml").NotExist() { + return true + } + return false + }, paths.FilterDirectories(), paths.FilterOutNames("python", "sketch", ".cache")) + if err != nil { + slog.Error("unable to list apps", slog.String("error", err.Error())) + return usedByApps, err + } + appPaths.AddAllMissing(res) + } + + for _, file := range appPaths { + app, err := app.Load(file.String()) + if err != nil { + // we are not considering the broken apps + slog.Warn("unable to parse app.yaml, skipping", "path", file.String(), "error", err.Error()) + continue + } + + for _, b := range app.Descriptor.Bricks { + if b.ID == brickId { + id, err := idProvider.IDFromPath(app.FullPath) + if err != nil { + return usedByApps, fmt.Errorf("failed to get app ID for %s: %w", app.FullPath, err) + } + usedByApps = append(usedByApps, AppReference{ + Name: app.Name, + ID: id.String(), + Icon: app.Descriptor.Icon, + }) + break + } + } + } + return usedByApps, nil +} + type BrickCreateUpdateRequest struct { ID string `json:"-"` Model *string `json:"model"`