minepkg/minepkg

View on GitHub
internals/provider/modrinth.go

Summary

Maintainability
A
1 hr
Test Coverage
package provider

import (
    "context"
    "errors"
    "fmt"
    "io"
    "net/http"
    "strings"

    "github.com/minepkg/minepkg/internals/modrinth"
    "github.com/minepkg/minepkg/internals/ownhttp"
    "github.com/minepkg/minepkg/pkg/manifest"
    "golang.org/x/time/rate"
)

var (
    ErrVersionNotSupported       = errors.New("for modrinth version can only be '*', 'latest' a version id, sha1 or sha512")
    ErrCouldNotFindLatestVersion = errors.New("could not find latest version")
    ErrVersionHasNoFiles         = errors.New("version has no files")
)

type ModrinthProvider struct {
    Client *modrinth.Client
}

type modrinthResult struct {
    name    string
    version *modrinth.Version
    file    *modrinth.File
}

func (m *modrinthResult) Lock() *manifest.DependencyLock {
    lock := &manifest.DependencyLock{
        Name:        m.name,
        Version:     m.version.ID,
        VersionName: m.version.VersionNumber,
        Type:        "mod",
        URL:         m.file.URL,
        Provider:    "modrinth",
        Sha512:      m.file.Hashes.Sha512,
    }

    return lock
}

func (m *modrinthResult) Dependencies() []*manifest.InterpretedDependency {
    return nil
}

func NewModrinthProvider() *ModrinthProvider {
    // http client that is throttled
    client := http.Client{}
    // limit to 5 requests per second (https://github.com/modrinth/labrinth/blob/1e4d07a52c441030374d052a44dddaaaa25d1f87/src/main.rs#L254-L255)
    limiter := rate.NewLimiter(5, 120)
    client.Transport = ownhttp.NewThrottleTransport(client.Transport, limiter)
    client.Transport = ownhttp.NewAddHeaderTransport(client.Transport)

    return &ModrinthProvider{
        Client: modrinth.New(&client),
    }
}

func (m *ModrinthProvider) Name() string { return "modrinth" }

func (m *ModrinthProvider) Resolve(ctx context.Context, request *Request) (Result, error) {

    var wantedVersion *modrinth.Version

    var err error
    if request.Dependency.Version == "*" || request.Dependency.Version == "latest" {
        wantedVersion, err = m.resolveLatest(ctx, request)
    } else {
        wantedVersion, err = m.resolveById(ctx, request.Dependency.Version)
    }
    if err != nil {
        return nil, err
    }

    // this should never happen, we assume all versions have files
    if len(wantedVersion.Files) == 0 {
        return nil, ErrVersionHasNoFiles
    }

    return &modrinthResult{
        name:    request.Dependency.Name,
        version: wantedVersion,
        file:    fileFromVersion(wantedVersion),
    }, nil
}

func (m *ModrinthProvider) ResolveLatest(ctx context.Context, request *Request) (Result, error) {
    version, err := m.resolveLatest(ctx, request)
    if err != nil {
        return nil, err
    }

    if len(version.Files) == 0 {
        return nil, ErrVersionHasNoFiles
    }

    return &modrinthResult{
        name:    request.Dependency.Name,
        version: version,
        file:    fileFromVersion(version),
    }, nil
}

func (m *ModrinthProvider) resolveById(ctx context.Context, id string) (*modrinth.Version, error) {
    switch len(id) {
    case 8:
        // we fetch by version id
        return m.Client.GetVersion(ctx, id)
    case 40:
        fallthrough
    case 128:
        // we fetch the version by hash
        return m.Client.GetVersionFile(ctx, id)
    default:
        return nil, ErrVersionNotSupported
    }
}

func (m *ModrinthProvider) resolveLatest(ctx context.Context, request *Request) (*modrinth.Version, error) {
    query := &modrinth.ListProjectVersionQuery{
        Loaders:      []string{request.Requirements.PlatformName()},
        GameVersions: []string{request.Requirements.MinecraftVersion()},
    }

    versions, err := m.Client.ListProjectVersion(ctx, request.Dependency.Name, query)
    if err != nil {
        return nil, err
    }

    if len(versions) == 0 {
        return nil, ErrCouldNotFindLatestVersion
    }

    // grab the first version (not sure if this is good)
    return &versions[0], nil
}

func (m *ModrinthProvider) Fetch(ctx context.Context, toFetch Result) (io.Reader, int, error) {
    req, err := http.NewRequestWithContext(ctx, "GET", toFetch.Lock().URL, nil)
    if err != nil {
        return nil, 0, err
    }

    fileRes, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, 0, err
    }

    return fileRes.Body, int(fileRes.ContentLength), nil
}

func (m *ModrinthProvider) CanConvertURL(url string) bool {
    return strings.HasPrefix(url, "https://cdn.modrinth.com/data/")
}

func (m *ModrinthProvider) ConvertURL(ctx context.Context, url string) (string, error) {
    if !m.CanConvertURL(url) {
        return "", fmt.Errorf("url %s is not a modrinth CDN url", url)
    }

    // url looks like https://cdn.modrinth.com/data/iFnEtHsI/versions/1.4.6/alaskanativecraft-1.4.6.jar
    // we need to extract the id (iFnEtHsI) and the version (1.4.6)

    // id is easy cause it is always the same length
    id := url[30:38]

    // version starts at the same position, but we need to find the end
    version := strings.SplitN(url[48:], "/", 2)[0]

    // now we can fetch the version
    versions, err := m.Client.ListProjectVersion(ctx, id, nil)
    if err != nil {
        return "", err
    }

    // now we need to find the version we want
    var wantedVersion *modrinth.Version

    for _, v := range versions {
        if v.VersionNumber == version || v.ID == version {
            wantedVersion = &v
            break
        }
    }

    if wantedVersion == nil {
        return "", fmt.Errorf("version %s not found", version)
    }

    // get the project slug
    project, err := m.Client.GetProject(ctx, id)
    if err != nil {
        return "", err
    }

    // now we can return install pkgid
    return fmt.Sprintf("modrinth:%s@%s", project.Slug, wantedVersion.ID), nil
}

func fileFromVersion(version *modrinth.Version) *modrinth.File {
    if len(version.Files) == 0 {
        return nil
    }

    // grab primary version, fallback to latest
    resolvedFile := version.Files[0]
    for _, f := range version.Files {
        if f.Primary {
            resolvedFile = f
            break
        }
    }

    return &resolvedFile
}