diff --git a/main.go b/main.go index cae10d0..94b5774 100644 --- a/main.go +++ b/main.go @@ -88,6 +88,7 @@ func main() { // Exposes GET /api/v1/providers for the Angular frontend engine.Register(NewProvidersAPI(reg, rm)) engine.Register(NewWorkspaceAPI(cwd)) + engine.Register(NewPackageToolsAPI(nil)) // ── Core framework ───────────────────────────────────────── c, err := core.New( diff --git a/main_linux.go b/main_linux.go index 25f3040..2681ef7 100644 --- a/main_linux.go +++ b/main_linux.go @@ -69,6 +69,7 @@ func main() { rm := NewRuntimeManager(engine) engine.Register(NewProvidersAPI(reg, rm)) engine.Register(NewWorkspaceAPI(cwd)) + engine.Register(NewPackageToolsAPI(nil)) c, err := core.New( core.WithName("ws", func(c *core.Core) (any, error) { diff --git a/packages.go b/packages.go new file mode 100644 index 0000000..72fef4b --- /dev/null +++ b/packages.go @@ -0,0 +1,318 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "dappco.re/go/core/scm/marketplace" + "forge.lthn.ai/core/api" + "github.com/gin-gonic/gin" +) + +const defaultMarketplaceAPIURL = "http://127.0.0.1:9880/api/v1/scm" + +// PackageToolsAPI proxies package marketplace lookups and installs to the +// upstream go-scm SCM provider. +type PackageToolsAPI struct { + client *MarketplaceClient +} + +// MarketplaceClient talks to the upstream SCM provider marketplace API. +type MarketplaceClient struct { + baseURL *url.URL + client *http.Client +} + +// PackageInstallResult summarises a successful install. +type PackageInstallResult struct { + Installed bool `json:"installed"` + Code string `json:"code"` +} + +// PackageSearchResponse groups query metadata with search results. +type PackageSearchResponse struct { + Query string `json:"query,omitempty"` + Category string `json:"category,omitempty"` + Packages []marketplace.Module `json:"packages"` +} + +// PackageInfoResponse returns details for a single marketplace package. +type PackageInfoResponse struct { + Package marketplace.Module `json:"package"` +} + +type marketplaceAPIError struct { + Code string + Message string + Details any +} + +func (e *marketplaceAPIError) Error() string { + if e == nil { + return "marketplace request failed" + } + if e.Details != nil { + return fmt.Sprintf("%s: %s (%v)", e.Code, e.Message, e.Details) + } + return fmt.Sprintf("%s: %s", e.Code, e.Message) +} + +func NewMarketplaceClient(baseURL string) *MarketplaceClient { + if baseURL == "" { + baseURL = os.Getenv("CORE_SCM_API_URL") + } + if baseURL == "" { + baseURL = defaultMarketplaceAPIURL + } + + parsed, err := url.Parse(baseURL) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + parsed, _ = url.Parse(defaultMarketplaceAPIURL) + } + + return &MarketplaceClient{ + baseURL: parsed, + client: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +func NewPackageToolsAPI(client *MarketplaceClient) *PackageToolsAPI { + if client == nil { + client = NewMarketplaceClient("") + } + return &PackageToolsAPI{client: client} +} + +func (p *PackageToolsAPI) Name() string { return "pkg-tools-api" } +func (p *PackageToolsAPI) BasePath() string { return "/api/v1/pkg" } + +func (p *PackageToolsAPI) RegisterRoutes(rg *gin.RouterGroup) { + rg.GET("/search", p.search) + rg.GET("/info/:code", p.info) + rg.POST("/install/:code", p.install) +} + +func (p *PackageToolsAPI) search(c *gin.Context) { + query := strings.TrimSpace(c.Query("q")) + category := strings.TrimSpace(c.Query("category")) + + results, err := p.client.Search(c.Request.Context(), query, category) + if err != nil { + c.JSON(http.StatusBadGateway, api.Fail("marketplace_unavailable", err.Error())) + return + } + + c.JSON(http.StatusOK, api.OK(PackageSearchResponse{ + Query: query, + Category: category, + Packages: results, + })) +} + +func (p *PackageToolsAPI) info(c *gin.Context) { + code := strings.TrimSpace(c.Param("code")) + if code == "" { + c.JSON(http.StatusBadRequest, api.Fail("invalid_request", "code is required")) + return + } + + pkg, err := p.client.Info(c.Request.Context(), code) + if err != nil { + c.JSON(marketplaceErrorStatus(err), api.Fail(marketplaceErrorCode(err, "marketplace_lookup_failed"), marketplaceErrorMessage(err))) + return + } + + c.JSON(http.StatusOK, api.OK(PackageInfoResponse{Package: pkg})) +} + +func (p *PackageToolsAPI) install(c *gin.Context) { + code := strings.TrimSpace(c.Param("code")) + if code == "" { + c.JSON(http.StatusBadRequest, api.Fail("invalid_request", "code is required")) + return + } + + result, err := p.client.Install(c.Request.Context(), code) + if err != nil { + c.JSON(marketplaceErrorStatus(err), api.Fail(marketplaceErrorCode(err, "marketplace_install_failed"), marketplaceErrorMessage(err))) + return + } + + c.JSON(http.StatusOK, api.OK(result)) +} + +func (c *MarketplaceClient) Search(ctx context.Context, query, category string) ([]marketplace.Module, error) { + var resp api.Response[[]marketplace.Module] + if err := c.get(ctx, "/marketplace", map[string]string{ + "q": query, + "category": category, + }, &resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +func (c *MarketplaceClient) Info(ctx context.Context, code string) (marketplace.Module, error) { + var resp api.Response[marketplace.Module] + if err := c.get(ctx, "/marketplace/"+url.PathEscape(code), nil, &resp); err != nil { + return marketplace.Module{}, err + } + return resp.Data, nil +} + +func (c *MarketplaceClient) Install(ctx context.Context, code string) (PackageInstallResult, error) { + var resp api.Response[PackageInstallResult] + if err := c.post(ctx, "/marketplace/"+url.PathEscape(code)+"/install", nil, &resp); err != nil { + return PackageInstallResult{}, err + } + if resp.Data.Code == "" { + resp.Data.Code = code + } + return resp.Data, nil +} + +func (c *MarketplaceClient) get(ctx context.Context, path string, query map[string]string, out any) error { + return c.request(ctx, http.MethodGet, path, query, nil, out) +} + +func (c *MarketplaceClient) post(ctx context.Context, path string, query map[string]string, out any) error { + return c.request(ctx, http.MethodPost, path, query, nil, out) +} + +func (c *MarketplaceClient) request(ctx context.Context, method, path string, query map[string]string, body any, out any) error { + if c == nil || c.baseURL == nil { + return fmt.Errorf("marketplace client is not configured") + } + + u := *c.baseURL + u.Path = strings.TrimRight(u.Path, "/") + path + q := u.Query() + for key, value := range query { + if strings.TrimSpace(value) == "" { + continue + } + q.Set(key, value) + } + u.RawQuery = q.Encode() + + var reqBody *strings.Reader + if body == nil { + reqBody = strings.NewReader("") + } else { + encoded, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("encode request body: %w", err) + } + reqBody = strings.NewReader(string(encoded)) + } + + req, err := http.NewRequestWithContext(ctx, method, u.String(), reqBody) + if err != nil { + return err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + res, err := c.client.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + raw, err := io.ReadAll(res.Body) + if err != nil { + return err + } + + if out == nil { + if res.StatusCode != http.StatusOK { + return fmt.Errorf("marketplace API returned %s", res.Status) + } + return nil + } + + if err := json.Unmarshal(raw, out); err != nil { + return err + } + + if res.StatusCode != http.StatusOK { + if err := responseEnvelopeError(out); err != nil { + return err + } + return fmt.Errorf("marketplace API returned %s", res.Status) + } + + return responseEnvelopeError(out) +} + +func responseEnvelopeError(out any) error { + if resp, ok := out.(*api.Response[[]marketplace.Module]); ok && !resp.Success { + return responseError(resp.Error) + } + if resp, ok := out.(*api.Response[marketplace.Module]); ok && !resp.Success { + return responseError(resp.Error) + } + if resp, ok := out.(*api.Response[PackageInstallResult]); ok && !resp.Success { + return responseError(resp.Error) + } + return nil +} + +func responseError(errObj *api.Error) error { + if errObj == nil { + return fmt.Errorf("marketplace request failed") + } + return &marketplaceAPIError{ + Code: errObj.Code, + Message: errObj.Message, + Details: errObj.Details, + } +} + +func marketplaceErrorCode(err error, fallback string) string { + if err == nil { + return fallback + } + if apiErr, ok := err.(*marketplaceAPIError); ok && apiErr.Code != "" { + return apiErr.Code + } + return fallback +} + +func marketplaceErrorMessage(err error) string { + if err == nil { + return "marketplace request failed" + } + if apiErr, ok := err.(*marketplaceAPIError); ok { + if apiErr.Details != nil { + return fmt.Sprintf("%s (%v)", apiErr.Message, apiErr.Details) + } + return apiErr.Message + } + return err.Error() +} + +func marketplaceErrorStatus(err error) int { + if apiErr, ok := err.(*marketplaceAPIError); ok { + if apiErr.Code == "not_found" { + return http.StatusNotFound + } + } + msg := strings.ToLower(err.Error()) + if strings.Contains(msg, "not found") || strings.Contains(msg, "404") { + return http.StatusNotFound + } + return http.StatusBadGateway +} diff --git a/packages_test.go b/packages_test.go new file mode 100644 index 0000000..411a570 --- /dev/null +++ b/packages_test.go @@ -0,0 +1,142 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "dappco.re/go/core/scm/marketplace" + "forge.lthn.ai/core/api" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPackageToolsAPI_Name(t *testing.T) { + apiSvc := NewPackageToolsAPI(nil) + assert.Equal(t, "pkg-tools-api", apiSvc.Name()) + assert.Equal(t, "/api/v1/pkg", apiSvc.BasePath()) +} + +func TestPackageToolsAPI_Search_Good(t *testing.T) { + gin.SetMode(gin.TestMode) + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/scm/marketplace", r.URL.Path) + assert.Equal(t, "alpha", r.URL.Query().Get("q")) + assert.Equal(t, "tool", r.URL.Query().Get("category")) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(api.OK([]marketplace.Module{ + {Code: "alpha", Name: "Alpha", Category: "tool"}, + })) + })) + defer upstream.Close() + + router := gin.New() + apiSvc := NewPackageToolsAPI(NewMarketplaceClient(upstream.URL + "/api/v1/scm")) + rg := router.Group(apiSvc.BasePath()) + apiSvc.RegisterRoutes(rg) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/pkg/search?q=alpha&category=tool", nil) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var resp api.Response[PackageSearchResponse] + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.True(t, resp.Success) + require.Len(t, resp.Data.Packages, 1) + assert.Equal(t, "alpha", resp.Data.Packages[0].Code) +} + +func TestPackageToolsAPI_Info_Good(t *testing.T) { + gin.SetMode(gin.TestMode) + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/scm/marketplace/alpha", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(api.OK(marketplace.Module{ + Code: "alpha", + Name: "Alpha", + })) + })) + defer upstream.Close() + + router := gin.New() + apiSvc := NewPackageToolsAPI(NewMarketplaceClient(upstream.URL + "/api/v1/scm")) + rg := router.Group(apiSvc.BasePath()) + apiSvc.RegisterRoutes(rg) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/pkg/info/alpha", nil) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var resp api.Response[PackageInfoResponse] + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.True(t, resp.Success) + assert.Equal(t, "alpha", resp.Data.Package.Code) +} + +func TestPackageToolsAPI_Info_NotFound(t *testing.T) { + gin.SetMode(gin.TestMode) + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(api.Fail("not_found", "provider not found in marketplace")) + })) + defer upstream.Close() + + router := gin.New() + apiSvc := NewPackageToolsAPI(NewMarketplaceClient(upstream.URL + "/api/v1/scm")) + rg := router.Group(apiSvc.BasePath()) + apiSvc.RegisterRoutes(rg) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/pkg/info/missing", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) + + var resp api.Response[PackageInfoResponse] + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.False(t, resp.Success) + require.NotNil(t, resp.Error) + assert.Equal(t, "not_found", resp.Error.Code) +} + +func TestPackageToolsAPI_Install_Good(t *testing.T) { + gin.SetMode(gin.TestMode) + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v1/scm/marketplace/alpha/install", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(api.OK(PackageInstallResult{ + Installed: true, + Code: "alpha", + })) + })) + defer upstream.Close() + + router := gin.New() + apiSvc := NewPackageToolsAPI(NewMarketplaceClient(upstream.URL + "/api/v1/scm")) + rg := router.Group(apiSvc.BasePath()) + apiSvc.RegisterRoutes(rg) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/v1/pkg/install/alpha", nil) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var resp api.Response[PackageInstallResult] + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.True(t, resp.Success) + assert.True(t, resp.Data.Installed) + assert.Equal(t, "alpha", resp.Data.Code) +}