ide/packages.go
Virgil efed2fc3ec
All checks were successful
Security Scan / security (push) Successful in 13s
Test / test (push) Successful in 6m35s
feat(ide): add package marketplace tools
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 19:02:37 +00:00

318 lines
8 KiB
Go

// 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
}