Skip to content

Commit

Permalink
Merge branch 'development' into main
Browse files Browse the repository at this point in the history
  • Loading branch information
froehlichA committed Apr 17, 2022
2 parents 50920c7 + 348a996 commit fe09543
Show file tree
Hide file tree
Showing 74 changed files with 746 additions and 665 deletions.
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"**/.DS_Store": true,
"**/Thumbs.db": true,
"nimcache/": true,
"testresults/": true
"testresults/": true,
"tests/**/*.exe": true
},
"nim.nimprettyIndent": 2
}
2 changes: 1 addition & 1 deletion pax.nimble
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ bin = @["pax"]

# Dependencies

requires "nim >= 1.4.6"
requires "nim >= 1.6.4"
requires "regex >= 0.19.0"
requires "therapist >= 0.2.0"
requires "zippy >= 0.6.2"
Expand Down
101 changes: 76 additions & 25 deletions src/api/cfclient.nim
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
## Provides functions for connecting to the CF proxy [https://github.com/bmpm-mc/cfproxy].
## Provides functions for connecting to the CF proxy [https://cfproxy.bmpm.workers.dev].
##
## The proxy connects to the official API internally, and has capabilities like:
## - Searching for a addon.
Expand All @@ -8,53 +8,92 @@
## Docs for the official API are available at https://docs.curseforge.com.
## Requests to the proxy stay the same, except the base URL is switched out.

import asyncdispatch, json, options, strutils
import std/[asyncdispatch, json, options, sequtils, strutils, sugar, tables]
import uri except Url
import cfcore, http

const
## base url of the cfproxy endpoint
addonsBaseUrl = "https://cfproxy.fly.dev"
addonsBaseUrl = "https://cfproxy.bmpm.workers.dev"
## base url of the curse metadata api endpoint
## used for retrieving mods by their slug, which isn't possible with the curse api
addonsSlugBaseUrl = "https://curse.nikky.moe/graphql"

type
CfClientError* = object of HttpRequestError

proc sortTo[T, X](s: seq[T], x: seq[X], pred: proc (x: T): X): seq[T] =
## sort `s` so that the order of its items matches `x`.
## `pred` should be a function that returns a unique value to which `s` is sorted.
assert s.len == x.len

var table = initTable[X, T]()
for sItem in s:
table[pred(sItem)] = sItem
for xItem in x:
result.add(table[xItem])

proc fetchAddonsByQuery*(query: string, category: Option[CfAddonGameCategory]): Future[seq[CfAddon]] {.async.} =
## retrieves all addons that match the given `query` search and `category`.
let encodedQuery = encodeUrl(query, usePlus = false)
var url = addonsBaseUrl & "/v1/mods/search?gameId=432&pageSize=50&sortField=6&sortOrder=desc&searchFilter=" & encodedQuery
if category.isSome:
url = url & "&classId=" & $ord(category.get())
try:
return get(url.Url).await.parseJson["data"].addonsFromForgeSvc
except HttpRequestError:
return @[]
return get(url.Url).await.parseJson["data"].addonsFromForgeSvc

proc fetchAddonsByQuery*(query: string, category: CfAddonGameCategory): Future[seq[CfAddon]] =
## retrieves all addons that match the given `query` search and `category`.
return fetchAddonsByQuery(query, category = some(category))

proc fetchAddonsByQuery*(query: string): Future[seq[CfAddon]] {.async.} =
return await fetchAddonsByQuery(query, category = none[CfAddonGameCategory]())
proc fetchAddonsByQuery*(query: string): Future[seq[CfAddon]] =
## retrieves all addons that match the given `query` search.
return fetchAddonsByQuery(query, category = none[CfAddonGameCategory]())

proc fetchAddon*(projectId: int): Future[Option[CfAddon]] {.async.} =
proc fetchAddon*(projectId: int): Future[CfAddon] {.async.} =
## get the addon with the given `projectId`.
let url = addonsBaseUrl & "/v1/mods/" & $projectId
try:
return get(url.Url).await.parseJson["data"].addonFromForgeSvc.some
return get(url.Url).await.parseJson["data"].addonFromForgeSvc
except HttpRequestError:
return none[CfAddon]()
raise newException(CfClientError, "addon with project id '" & $projectId & "' not found.")

proc fetchAddons*(projectIds: seq[int], chunk = true): Future[seq[CfAddon]] {.async.} =
## get all addons with their given `projectId`.
##
## chunks the projectIds to minimize request size and to pinpoint errors better.
if projectIds.len > 10 and chunk:
let futures: seq[Future[seq[CfAddon]]] = collect:
for chunkedIds in projectIds.distribute(int(projectIds.len / 10), spread = true):
fetchAddons(chunkedIds, chunk = false)
let addons: seq[seq[CfAddon]] = await all(futures)
return collect:
for addonSeq in addons:
for addon in addonSeq:
addon
else:
let url = addonsBaseUrl & "/v1/mods/"
let body = %* { "modIds": projectIds }
try:
let addons = post(url.Url, $body).await.parseJson["data"].addonsFromForgeSvc
if addons.len != projectIds.len:
raise newException(CfClientError, "one of the addons of project ids '" & $projectIds & "' was not found.")
return addons.sortTo(projectIds, (x) => x.projectId)
except HttpRequestError:
let futures: seq[Future[CfAddon]] = collect:
for projectId in projectIds:
fetchAddon(projectId)
return await all(futures)

proc fetchAddon*(slug: string): Future[Option[CfAddon]] {.async.} =
proc fetchAddon*(slug: string): Future[CfAddon] {.async.} =
## get the addon matching the `slug`.
let reqBody = %* {
"query": "{ addons(slug: \"" & slug & "\") { id }}"
}
let curseProxyInfo = await post(addonsSlugBaseUrl.Url, body = $reqBody)
var projectId: int
try:
let addons = curseProxyInfo.parseJson["data"]["addons"]
if addons.len == 0:
return none[CfAddon]()
projectId = addons[0]["id"].getInt()
except KeyError:
return none[CfAddon]()
let addons = curseProxyInfo.parseJson["data"]["addons"]
if addons.len == 0:
raise newException(CfClientError, "addon with slug '" & slug & "' not found")
let projectId = addons[0]["id"].getInt()
return await fetchAddon(projectId)

proc fetchAddonFiles*(projectId: int): Future[seq[CfAddonFile]] {.async.} =
Expand All @@ -63,12 +102,24 @@ proc fetchAddonFiles*(projectId: int): Future[seq[CfAddonFile]] {.async.} =
try:
return get(url.Url).await.parseJson["data"].addonFilesFromForgeSvc
except HttpRequestError:
return @[]
raise newException(CfClientError, "addon with project id '" & $projectId & "' not found.")

proc fetchAddonFiles*(fileIds: seq[int]): Future[seq[CfAddonFile]] {.async.} =
## get all addon files with their given `fileIds`.
let url = addonsBaseUrl & "/v1/mods/files"
let body = %* { "fileIds": fileIds }
try:
let addonFiles = post(url.Url, $body).await.parseJson["data"].addonFilesFromForgeSvc
if addonFiles.len != fileIds.len:
raise newException(CfClientError, "one of the addon files of file ids '" & $fileIds & "' was not found.")
return addonFiles.sortTo(fileIds, (x) => x.fileId)
except HttpRequestError:
raise newException(CfClientError, "one of the addon files of file ids '" & $fileIds & "' was not found.")

proc fetchAddonFile*(projectId: int, fileId: int): Future[Option[CfAddonFile]] {.async.} =
proc fetchAddonFile*(projectId: int, fileId: int): Future[CfAddonFile] {.async.} =
## get the addon file with the given `fileId` & `projectId`.
let url = addonsBaseUrl & "/v1/mods/" & $projectId & "/files/" & $fileId
try:
return get(url.Url).await.parseJson["data"].addonFileFromForgeSvc.some
return get(url.Url).await.parseJson["data"].addonFileFromForgeSvc
except HttpRequestError:
return none[CfAddonFile]()
raise newException(CfClientError, "addon with project & file id '" & $projectId & ':' & $fileId & "' not found.")
13 changes: 8 additions & 5 deletions src/api/cfcore.nim
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
## Provides the types returned by the `cfclient` module.
## Provides the types returned by the Curseforge API.
##
## Curseforge has two main entities:
## - Addons, which are modifications to the source game in one way or another.
## In Minecraft, mods are one type of CF addon, resourcepacks are another.
## - Addon files, which are files attached to one addon and can be seen of versions of an addon.
## - Addons (https://docs.curseforge.com/#search-mods),
## which are modifications to the source game in one way or another.
## For Minecraft, mods are one type of CF addon, resourcepacks are another.
## - Addon files (https://docs.curseforge.com/#curseforge-core-api-files),
## which are files attached to an addon and can be seen as versions of an addon.

import json, regex, sequtils, strutils, sugar
import std/[json, sequtils, strutils, sugar]
import regex
import ../modpack/version

type
Expand Down
14 changes: 7 additions & 7 deletions src/api/http.nim
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
## A simple wrapper for the `httpclient` module.
## Used for convenience over creating a `newAsyncHttpClient()` instance.

import asyncdispatch, httpclient, os
import std/[asyncdispatch, httpclient, os]
export HttpRequestError

let
Expand All @@ -17,24 +17,24 @@ type

proc `$`(v: Url): string {.borrow.}

proc getHttpClient(): AsyncHttpClient =
template getHttpClient(): AsyncHttpClient =
## returns an async http client using the proxy (if it exists).
let http = case proxy:
of "": newAsyncHttpClient()
else: newAsyncHttpClient(proxy = newProxy(proxy))
http.headers = newHttpHeaders({"Content-Type": "application/json"})
return http
http

proc get*(url: Url): Future[string] {.async.} =
proc get*(url: Url): Future[string] =
## creates a GET request targeting the given `url`.
## throws OSError or HttpRequestError if the request failed.
## returns the body of the response.
let http = getHttpClient()
return await http.getContent($url)
result = http.getContent($url)

proc post*(url: Url, body: string = ""): Future[string] {.async.} =
proc post*(url: Url, body: string = ""): Future[string] =
## creates a POST request targeting the given `url`, with an optional `body`.
## throws OSError or HttpRequestError if the request failed.
## returns the body of the response.
let http = getHttpClient()
return await http.postContent($url, body)
result = http.postContent($url, body)
48 changes: 26 additions & 22 deletions src/api/metadata.nim
Original file line number Diff line number Diff line change
@@ -1,54 +1,58 @@
## Provides `getModLoaderId` which retrieves the modloader id (a string specifying
## the type & version of the modloader) given a minecraft version and modloader.
## the type & version of the modloader) given a minecraft version and type of modloader.
##
## Pax currently has support for the Forge & Fabric modloader. The information about
## what loader version corresponds to what minecraft version is retrieved online
## and should be generally up-to-date 99% of the time.

import asyncdispatch, json, options, strutils, sugar
import std/[asyncdispatch, json, strutils]
import http
import ../modpack/version, ../modpack/loader

const
## base url of the fabric metadata endpoint
fabricBaseUrl = "https://meta.fabricmc.net/v2/versions/loader/"
## base url of the curse metadata api endpoint
forgeBaseUrl = "http://raw.githubusercontent.com/MultiMC/meta-upstream/master/forge/derived_index.json"
forgeBaseUrl = "https://cfproxy.fly.dev/v1/minecraft/modloader"

proc getFabricLoaderVersion(mcVersion: Version): Future[Option[string]] {.async.} =
type
MetadataClientError* = object of HttpRequestError

proc getFabricLoaderVersion(mcVersion: Version): Future[string] {.async.} =
## get the fabric loader version fitting for the given minecraft version
let url = fabricBaseUrl & $mcVersion
var json: JsonNode
try:
json = get(url.Url).await.parseJson
let json: JsonNode = try:
get(url.Url).await.parseJson
except HttpRequestError:
return none[string]()
raise newException(MetadataClientError, "'" & $mcVersion & "' is not a valid mc version.")
let loaderElems = json.getElems()
if loaderElems.len == 0:
return none[string]()
raise newException(MetadataClientError, "'" & $mcVersion & "' is not a valid mc version.")
let ver = loaderElems[0]["loader"]["version"].getStr()
return some(ver)
return ver

proc getForgeLoaderVersion(mcVersion: Version, latest: bool): Future[Option[string]] {.async.} =
proc getForgeLoaderVersion(mcVersion: Version, latest: bool): Future[string] {.async.} =
## get the forge loader version fitting for the given minecraft version
let json = get(forgeBaseUrl.Url).await.parseJson
let recommendedVersion = json{"by_mcversion", $mcVersion, "recommended"}.getStr()
let latestVersion = json{"by_mcversion", $mcVersion, "latest"}.getStr()
let forgeVersion = if latest:
latestVersion
else:
if $recommendedVersion != "": recommendedVersion else: latestVersion
return if forgeVersion != "": some(forgeVersion) else: none[string]()
let url = forgeBaseUrl & "?version=" & $mcVersion
let json: JsonNode = try:
get(url.Url).await.parseJson
except HttpRequestError:
raise newException(MetadataClientError, "'" & $mcVersion & "' is not a valid mc version.")
let searchKey = if latest: "latest" else: "recommended"
for item in json["data"].items():
if item[searchKey].getBool():
return item["name"].getStr()
raise newException(MetadataClientError, "'" & $mcVersion & "' is not a valid mc version.")

proc toModloaderId(loaderVersion: string, loader: Loader): string =
## get the modloader id fitting for the given loader version and loader
return case loader:
of Loader.Forge: "forge-" & loaderVersion.split("-")[1]
of Loader.Fabric: "fabric-" & loaderVersion

proc getModloaderId*(mcVersion: Version, loader: Loader, latest: bool = false): Future[Option[string]] {.async.} =
proc getModloaderId*(mcVersion: Version, loader: Loader, latest: bool = false): Future[string] {.async.} =
## get the modloader id fitting for the given minecraft version and loader
let loaderVersion = case loader:
of Loader.Forge: await mcVersion.getForgeLoaderVersion(latest)
of Loader.Fabric: await mcVersion.getFabricLoaderVersion()
return loaderVersion.map((x) => toModloaderId(x, loader))
of Loader.Fabric: mcVersion.getFabricLoaderVersion().await.toModloaderId(loader)
return loaderVersion

0 comments on commit fe09543

Please sign in to comment.