diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..343becf --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,14 @@ +version: 2 +updates: + + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "daily" + assignees: + - "nothub" + reviewers: + - "nothub" + commit-message: + prefix: "gomod" + include: "scope" diff --git a/.github/workflows/verify.yaml b/.github/workflows/verify.yaml new file mode 100644 index 0000000..7ff590b --- /dev/null +++ b/.github/workflows/verify.yaml @@ -0,0 +1,14 @@ +name: "🚔" +on: [ push, pull_request ] +jobs: + verify: + name: "Verify" + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-go@v3 + with: + go-version-file: 'go.mod' + check-latest: true + cache: true + - run: make lint test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bddffdb --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +* +!*/ + +!/.github/**/*.yaml + +!/.gitattributes +!/.gitignore + +!/Makefile + +!/go.mod +!/go.sum +!/**/*.go diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bff6301 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +MODNAME = $(shell go list -m) +BINNAME = $(shell basename $(MODNAME)) +THREADS = $(shell grep -c -E "^processor.*[0-9]+" "/proc/cpuinfo") + +$(BINNAME): lint test + go build -race -o $@ + +clean: + go clean + go mod tidy + +lint: + go vet + +test: + go test $(MODNAME)/api -parallel $(THREADS) + +.PHONY: clean lint test diff --git a/api/api_test.go b/api/api_test.go new file mode 100644 index 0000000..83572d9 --- /dev/null +++ b/api/api_test.go @@ -0,0 +1,165 @@ +package api + +import ( + "testing" +) + +func Test_GetProject_Success(t *testing.T) { + t.Parallel() + c := NewClient() + project, err := c.GetProject("fabric-api") + if err != nil { + t.Fatal(err) + } + if project.Slug != "fabric-api" { + t.Fatal("wrong slug!") + } + if project.ProjectType != "mod" { + t.Fatal("wrong type!") + } +} + +func Test_GetProject_404(t *testing.T) { + t.Parallel() + c := NewClient() + _, err := c.GetProject("x") + if err.Error() != "http status 404" { + t.Fatal("wrong status!") + } +} + +func TestClient_GetProjects_Count(t *testing.T) { + t.Parallel() + c := NewClient() + projects, err := c.GetProjects([]string{"P7dR8mSH", "XxWD5pD3", "x"}) + if err != nil { + t.Fatal(err) + } + if len(projects) != 2 { + t.Fatal("wrong count!") + } +} + +func TestClient_GetProjects_Slug(t *testing.T) { + t.Parallel() + c := NewClient() + projects, err := c.GetProjects([]string{"P7dR8mSH"}) + if err != nil { + t.Fatal(err) + } + if projects[0].Slug != "fabric-api" { + t.Fatal("wrong slug!") + } +} + +func TestClient_CheckProjectValidity_Slug(t *testing.T) { + t.Parallel() + c := NewClient() + response, err := c.CheckProjectValidity("fabric-api") + if err != nil { + t.Fatal(err) + } + if response.Id != "P7dR8mSH" { + t.Fatal("wrong id!") + } +} + +func TestClient_CheckProjectValidity_Id(t *testing.T) { + t.Parallel() + c := NewClient() + response, err := c.CheckProjectValidity("P7dR8mSH") + if err != nil { + t.Fatal(err) + } + if response.Id != "P7dR8mSH" { + t.Fatal("wrong id!") + } +} + +func TestClient_GetDependencies(t *testing.T) { + t.Parallel() + c := NewClient() + dependencies, err := c.GetDependencies("rinthereout") + if err != nil { + t.Fatal(err) + } + if len(dependencies.Projects) < 1 { + t.Fatal("wrong count!") + } +} + +func TestClient_GetProjectVersions_Count(t *testing.T) { + t.Parallel() + c := NewClient() + versions, err := c.GetProjectVersions("fabric-api", &GetProjectVersionsParams{}) + if err != nil { + t.Fatal(err) + } + if len(versions) < 1 { + t.Fatal("wrong count!") + } +} + +func TestClient_GetProjectVersions_Filter_Results(t *testing.T) { + t.Parallel() + c := NewClient() + versions, err := c.GetProjectVersions("fabric-api", &GetProjectVersionsParams{ + GameVersions: []string{"1.16.5"}, + }) + if err != nil { + t.Fatal(err) + } + if len(versions) < 1 { + t.Fatal("wrong count!") + } +} + +func TestClient_GetProjectVersions_Filter_NoResults(t *testing.T) { + t.Parallel() + c := NewClient() + versions, err := c.GetProjectVersions("fabric-api", &GetProjectVersionsParams{ + Loaders: []string{"forge"}, + }) + if err != nil { + t.Fatal(err) + } + if len(versions) > 0 { + t.Fatal("wrong count!") + } +} + +func TestClient_GetVersion(t *testing.T) { + t.Parallel() + c := NewClient() + version, err := c.GetVersion("IQ3UGSc2") + if err != nil { + t.Fatal(err) + } + if version.ProjectId != "P7dR8mSH" { + t.Fatal("wrong parent id!") + } +} + +func TestClient_GetVersions(t *testing.T) { + t.Parallel() + c := NewClient() + versions, err := c.GetVersions([]string{"IQ3UGSc2", "DrzwF8io", "foobar"}) + if err != nil { + t.Fatal(err) + } + if len(versions) != 2 { + t.Fatal("wrong count!") + } +} + +func TestClient_VersionFromHash(t *testing.T) { + t.Parallel() + c := NewClient() + version, err := c.VersionFromHash("619e250c133106bacc3e3b560839bd4b324dfda8", "sha1") + if err != nil { + t.Fatal(err) + } + if version.Id != "d5nXweHE" { + t.Fatal("wrong id!") + } +} diff --git a/api/client.go b/api/client.go new file mode 100644 index 0000000..487ed9c --- /dev/null +++ b/api/client.go @@ -0,0 +1,75 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "runtime/debug" + "strconv" +) + +type Client struct { + UserAgent string + HTTPClient *http.Client +} + +func NewClient() *Client { + userAgent := "gorinth" + info, ok := debug.ReadBuildInfo() + if ok { + userAgent = info.Main.Path + "/" + info.Main.Version + } + return &Client{ + UserAgent: userAgent, + HTTPClient: &http.Client{}, + } +} + +func (client *Client) buildRequest(method string, url string, body io.Reader) *http.Request { + request, err := http.NewRequest(method, url, body) + if err != nil { + log.Fatalln(err) + } + + request.Header.Set("User-Agent", client.UserAgent) + request.Header.Set("Accept", "application/json") + if request.Method == "POST" || request.Method == "PATCH" || request.Method == "PUT" { + request.Header.Set("Content-Type", "application/json") + } + + request.Close = true + + return request +} + +func (client *Client) sendRequest(method string, url string, body io.Reader, result interface{}) error { + response, err := client.HTTPClient.Do(client.buildRequest(method, url, body)) + if err != nil { + return err + } + + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + fmt.Println(err) + } + }(response.Body) + + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusBadRequest { + var e Error + if json.NewDecoder(response.Body).Decode(&e) != nil { + return errors.New("http status " + strconv.Itoa(response.StatusCode)) + } + return errors.New("http status " + strconv.Itoa(response.StatusCode) + " - " + e.Error + " " + e.Description) + } + + err = json.NewDecoder(response.Body).Decode(&result) + if err != nil { + return errors.New("http status " + strconv.Itoa(response.StatusCode) + " - " + err.Error()) + } + + return nil +} diff --git a/api/endpoints.go b/api/endpoints.go new file mode 100644 index 0000000..cf0b88f --- /dev/null +++ b/api/endpoints.go @@ -0,0 +1,188 @@ +package api + +import ( + url2 "net/url" +) + +const baseUrl = "https://api.modrinth.com/" +const apiVersion = "v2" +const apiUrl = baseUrl + apiVersion + +func (client *Client) LabrinthInfo() (*LabrinthInfo, error) { + url, err := url2.Parse(baseUrl) + if err != nil { + return nil, err + } + + labrinthInfo := LabrinthInfo{} + err = client.sendRequest("GET", url.String(), nil, &labrinthInfo) + if err != nil { + return nil, err + } + + return &labrinthInfo, nil +} + +/* projects */ + +// GetProject https://docs.modrinth.com/api-spec/#tag/projects/operation/getProject +func (client *Client) GetProject(id string) (*Project, error) { + url, err := url2.Parse(apiUrl + "/project/" + id) + if err != nil { + return nil, err + } + + project := Project{} + err = client.sendRequest("GET", url.String(), nil, &project) + if err != nil { + return nil, err + } + + return &project, nil +} + +// GetProjects https://docs.modrinth.com/api-spec/#tag/projects/operation/getProjects +func (client *Client) GetProjects(ids []string) ([]*Project, error) { + url, err := url2.Parse(apiUrl + "/projects") + if err != nil { + return nil, err + } + + query := url2.Values{} + query.Add("ids", arrayAsParam(ids)) + url.RawQuery = query.Encode() + + var projects []*Project + err = client.sendRequest("GET", url.String(), nil, &projects) + if err != nil { + return nil, err + } + + return projects, nil +} + +// CheckProjectValidity https://docs.modrinth.com/api-spec/#tag/projects/operation/checkProjectValidity +func (client *Client) CheckProjectValidity(id string) (*CheckResponse, error) { + url, err := url2.Parse(apiUrl + "/project/" + id + "/check") + if err != nil { + return nil, err + } + + var checkResponse CheckResponse + err = client.sendRequest("GET", url.String(), nil, &checkResponse) + if err != nil { + return nil, err + } + + return &checkResponse, nil +} + +// GetDependencies https://docs.modrinth.com/api-spec/#tag/projects/operation/getDependencies +func (client *Client) GetDependencies(id string) (*Dependencies, error) { + url, err := url2.Parse(apiUrl + "/project/" + id + "/dependencies") + if err != nil { + return nil, err + } + + var dependencies Dependencies + err = client.sendRequest("GET", url.String(), nil, &dependencies) + if err != nil { + return nil, err + } + + return &dependencies, nil +} + +/* versions */ + +// GetProjectVersions https://docs.modrinth.com/api-spec/#tag/versions/operation/getProjectVersions +func (client *Client) GetProjectVersions(id string, params *GetProjectVersionsParams) ([]*Version, error) { + url, err := url2.Parse(apiUrl + "/project/" + id + "/version") + if err != nil { + return nil, err + } + + query := url2.Values{} + if len(params.Loaders) > 0 { + query.Add("loaders", arrayAsParam(params.Loaders)) + } + if len(params.GameVersions) > 0 { + query.Add("game_versions", arrayAsParam(params.GameVersions)) + } + if params.FeaturedOnly { + query.Add("featured", "true") + } + url.RawQuery = query.Encode() + + var versions []*Version + err = client.sendRequest("GET", url.String(), nil, &versions) + if err != nil { + return nil, err + } + + return versions, nil +} + +type GetProjectVersionsParams struct { + Loaders []string + GameVersions []string + FeaturedOnly bool +} + +// GetVersion https://docs.modrinth.com/api-spec/#tag/versions/operation/getVersion +func (client *Client) GetVersion(id string) (*Version, error) { + url, err := url2.Parse(apiUrl + "/version/" + id) + if err != nil { + return nil, err + } + + var version Version + err = client.sendRequest("GET", url.String(), nil, &version) + if err != nil { + return nil, err + } + + return &version, nil +} + +// GetVersions https://docs.modrinth.com/api-spec/#tag/versions/operation/getVersions +func (client *Client) GetVersions(ids []string) ([]*Version, error) { + url, err := url2.Parse(apiUrl + "/versions") + if err != nil { + return nil, err + } + + query := url2.Values{} + query.Add("ids", arrayAsParam(ids)) + url.RawQuery = query.Encode() + + var versions []*Version + err = client.sendRequest("GET", url.String(), nil, &versions) + if err != nil { + return nil, err + } + + return versions, nil +} + +/* version files */ + +// VersionFromHash https://docs.modrinth.com/api-spec/#tag/version-files/operation/versionFromHash +func (client *Client) VersionFromHash(hash string, algorithm HashAlgo) (*Version, error) { + url, err := url2.Parse(apiUrl + "/version_file/" + hash) + if err != nil { + return nil, err + } + + query := url2.Values{} + query.Add("algorithm", string(algorithm)) + url.RawQuery = query.Encode() + + var version *Version + err = client.sendRequest("GET", url.String(), nil, &version) + if err != nil { + return nil, err + } + + return version, nil +} diff --git a/api/format.go b/api/format.go new file mode 100644 index 0000000..9e9f959 --- /dev/null +++ b/api/format.go @@ -0,0 +1,13 @@ +package api + +import ( + "fmt" + "strings" +) + +func arrayAsParam(arr []string) string { + for i := range arr { + arr[i] = fmt.Sprintf("%q", arr[i]) + } + return "[" + strings.Join(arr, ",") + "]" +} diff --git a/api/model.go b/api/model.go new file mode 100644 index 0000000..24c0679 --- /dev/null +++ b/api/model.go @@ -0,0 +1,158 @@ +package api + +type ProjectType string + +const ( + Mod ProjectType = "mod" + Modpack ProjectType = "modpack" + Plugin ProjectType = "plugin" + Resourcepack ProjectType = "resourcepack" +) + +type ProjectStatus string + +const ( + Approved ProjectStatus = "approved" + Rejected ProjectStatus = "rejected" + Draft ProjectStatus = "draft" + Unlisted ProjectStatus = "unlisted" + Archived ProjectStatus = "archived" + Processing ProjectStatus = "processing" + Unknown ProjectStatus = "unknown" +) + +type Environment string + +const ( + Required Environment = "required" + Optional Environment = "optional" + Unsupported Environment = "unsupported" +) + +type ReleaseChannel string + +const ( + Release ReleaseChannel = "release" + Beta ReleaseChannel = "beta" + Alpha ReleaseChannel = "alpha" +) + +type HashAlgo string + +const ( + Sha1 HashAlgo = "sha1" + Sha512 HashAlgo = "sha512" +) + +type LabrinthInfo struct { + About string `json:"about"` + Documentation string `json:"documentation"` + Name string `json:"name"` + Version string `json:"version"` +} + +type Project struct { + Slug string `json:"slug"` + Title string `json:"title"` + Description string `json:"description"` + Categories []string `json:"categories"` + ClientSide Environment `json:"client_side"` + ServerSide Environment `json:"server_side"` + Body string `json:"body"` + AdditionalCategories []string `json:"additional_categories"` + IssuesUrl string `json:"issues_url"` + SourceUrl string `json:"source_url"` + WikiUrl string `json:"wiki_url"` + DiscordUrl string `json:"discord_url"` + DonationUrls []DonationUrl `json:"donation_urls"` + ProjectType ProjectType `json:"project_type"` + Downloads int `json:"downloads"` + IconUrl string `json:"icon_url"` + Id string `json:"id"` + Team string `json:"team"` + ModeratorMessage ModeratorMessage `json:"moderator_message"` + Published string `json:"published"` + Updated string `json:"updated"` + Approved string `json:"approved"` + Followers int `json:"followers"` + Status ProjectStatus `json:"status"` + License License `json:"license"` + Versions []string `json:"versions"` + Gallery []GalleryItem `json:"gallery"` +} + +type DonationUrl struct { + Id string `json:"id"` + Platform string `json:"platform"` + Url string `json:"url"` +} + +type ModeratorMessage struct { + Message string `json:"message"` + Body string `json:"body"` +} + +type License struct { + Id string `json:"id"` + Name string `json:"name"` + Url string `json:"url"` +} + +type GalleryItem struct { + Url string `json:"url"` + Featured bool `json:"featured"` + Title string `json:"title"` + Description string `json:"description"` + Created string `json:"created"` +} + +type Version struct { + Name string `json:"name"` + VersionNumber string `json:"version_number"` + Changelog string `json:"changelog"` + Dependencies []Dependency `json:"dependencies"` + GameVersions []string `json:"game_versions"` + VersionType ReleaseChannel `json:"version_type"` + Loaders []string `json:"loaders"` + Featured bool `json:"featured"` + Id string `json:"id"` + ProjectId string `json:"project_id"` + AuthorId string `json:"author_id"` + DatePublished string `json:"date_published"` + Downloads int `json:"downloads"` + Files []File `json:"files"` +} + +type Dependency struct { + VersionId string `json:"version_id"` + ProjectId string `json:"project_id"` + FileName string `json:"file_name"` + DependencyType string `json:"dependency_type"` +} + +type File struct { + Hashes Hashes `json:"hashes"` + Url string `json:"url"` + Filename string `json:"filename"` + Primary bool `json:"primary"` + Size int `json:"size"` +} + +type Hashes struct { + Sha512 HashAlgo `json:"sha512"` + Sha1 HashAlgo `json:"sha1"` +} + +type Dependencies struct { + Projects []Project `json:"projects"` + Versions []Version `json:"versions"` +} + +type CheckResponse struct { + Id string `json:"id"` +} + +type Error struct { + Error string `json:"error"` + Description string `json:"description"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8170640 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/nothub/gorinth + +go 1.19 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/main.go b/main.go new file mode 100644 index 0000000..54fa816 --- /dev/null +++ b/main.go @@ -0,0 +1,18 @@ +package main + +import ( + "fmt" + "github.com/nothub/gorinth/api" + "log" +) + +func main() { + client := api.NewClient() + info, err := client.LabrinthInfo() + if err != nil { + log.Fatalln(err) + } + fmt.Println(info.About) + fmt.Println(info.Name, info.Version) + fmt.Println(info.Documentation) +} diff --git a/mrpack/model.go b/mrpack/model.go new file mode 100644 index 0000000..aa33cfd --- /dev/null +++ b/mrpack/model.go @@ -0,0 +1,41 @@ +package mrpack + +import "github.com/nothub/gorinth/api" + +const indexFile string = "modrinth.index.json" + +type Index struct { + FormatVersion int `json:"formatVersion"` + Game Game `json:"game"` + VersionId string `json:"versionId"` + Name string `json:"name"` + Summary string `json:"summary"` + Files []File `json:"files"` + Dependencies Dependencies `json:"dependencies"` +} + +type Game string + +const ( + Minecraft Game = "minecraft" +) + +type File struct { + Path string `json:"path"` + Hashes api.Hashes `json:"hashes"` + Env Env `json:"env"` + Downloads []string `json:"downloads"` // array of HTTPS URLs + FileSize int `json:"fileSize"` // size in bytes +} + +type Env struct { + Client api.Environment `json:"client"` + Server api.Environment `json:"server"` +} + +type Dependencies struct { + Minecraft string `json:"minecraft"` + Forge string `json:"forge"` + Fabric string `json:"fabric-loader"` + Quilt string `json:"quilt-loader"` +} diff --git a/mrpack/overrides.go b/mrpack/overrides.go new file mode 100644 index 0000000..bb10549 --- /dev/null +++ b/mrpack/overrides.go @@ -0,0 +1,15 @@ +package mrpack + +const ( + common string = "overrides" + client string = "client-overrides" + server string = "server-overrides" +) + +func OverrideDirsClient() []string { + return []string{common, client} +} + +func OverrideDirsServer() []string { + return []string{common, server} +}