feat(ide): add package marketplace tools
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
9f97d256cf
commit
efed2fc3ec
4 changed files with 462 additions and 0 deletions
1
main.go
1
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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
318
packages.go
Normal file
318
packages.go
Normal file
|
|
@ -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
|
||||
}
|
||||
142
packages_test.go
Normal file
142
packages_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue