319 lines
8 KiB
Go
319 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
|
||
|
|
}
|