From ba09db709390929a3265c5b5cdd884edb027d7d6 Mon Sep 17 00:00:00 2001 From: jvictorv Date: Fri, 8 May 2026 20:50:32 -0300 Subject: [PATCH] feat(modrinth): add Modrinth SDK package Add `@distilled.cloud/modrinth` workspace with full operation coverage generated from the Modrinth OpenAPI spec, plus CI integration across test, pr-package, release, and nuke workflows. --- .github/workflows/nuke.yml | 4 + .github/workflows/pr-package.yml | 5 +- .github/workflows/release.yml | 1 + .github/workflows/test.yml | 25 + bun.lock | 20 + packages/modrinth/README.md | 79 + packages/modrinth/package.json | 89 + .../001-add-error-responses.patch.json | 113 + .../002-add-mutation-bad-request.patch.json | 221 + packages/modrinth/scripts/generate.ts | 73 + packages/modrinth/scripts/nuke.ts | 475 ++ packages/modrinth/specs/openapi.yaml | 3965 +++++++++++++++++ packages/modrinth/src/category.ts | 4 + packages/modrinth/src/client.ts | 98 + packages/modrinth/src/credentials.ts | 58 + packages/modrinth/src/errors.ts | 47 + packages/modrinth/src/index.ts | 16 + .../src/operations/addFilesToVersion.ts | 37 + .../src/operations/addGalleryImage.ts | 48 + .../modrinth/src/operations/addTeamMember.ts | 27 + .../modrinth/src/operations/categoryList.ts | 31 + .../src/operations/changeProjectIcon.ts | 42 + .../modrinth/src/operations/changeUserIcon.ts | 28 + .../src/operations/checkProjectValidity.ts | 32 + .../modrinth/src/operations/createProject.ts | 149 + .../modrinth/src/operations/createVersion.ts | 122 + .../src/operations/deleteFileFromHash.ts | 31 + .../src/operations/deleteGalleryImage.ts | 29 + .../src/operations/deleteNotification.ts | 27 + .../src/operations/deleteNotifications.ts | 28 + .../modrinth/src/operations/deleteProject.ts | 26 + .../src/operations/deleteProjectIcon.ts | 28 + .../src/operations/deleteTeamMember.ts | 30 + .../src/operations/deleteThreadMessage.ts | 28 + .../modrinth/src/operations/deleteUserIcon.ts | 26 + .../modrinth/src/operations/deleteVersion.ts | 26 + .../src/operations/donationPlatformList.ts | 33 + .../modrinth/src/operations/followProject.ts | 26 + .../modrinth/src/operations/forgeUpdates.ts | 59 + .../src/operations/getDependencies.ts | 135 + .../src/operations/getFollowedProjects.ts | 71 + .../operations/getLatestVersionFromHash.ts | 93 + .../operations/getLatestVersionsFromHashes.ts | 95 + .../src/operations/getNotification.ts | 50 + .../src/operations/getNotifications.ts | 52 + .../modrinth/src/operations/getOpenReports.ts | 36 + .../src/operations/getPayoutHistory.ts | 40 + .../modrinth/src/operations/getProject.ts | 67 + .../src/operations/getProjectTeamMembers.ts | 72 + .../src/operations/getProjectVersions.ts | 95 + .../modrinth/src/operations/getProjects.ts | 67 + packages/modrinth/src/operations/getReport.ts | 36 + .../modrinth/src/operations/getReports.ts | 38 + .../modrinth/src/operations/getTeamMembers.ts | 67 + packages/modrinth/src/operations/getTeams.ts | 69 + packages/modrinth/src/operations/getThread.ts | 111 + .../modrinth/src/operations/getThreads.ts | 113 + packages/modrinth/src/operations/getUser.ts | 53 + .../src/operations/getUserFromAuth.ts | 49 + .../src/operations/getUserNotifications.ts | 58 + .../src/operations/getUserProjects.ts | 69 + packages/modrinth/src/operations/getUsers.ts | 55 + .../modrinth/src/operations/getVersion.ts | 81 + .../operations/getVersionFromIdOrNumber.ts | 98 + .../modrinth/src/operations/getVersions.ts | 85 + packages/modrinth/src/operations/index.ts | 76 + packages/modrinth/src/operations/joinTeam.ts | 26 + .../modrinth/src/operations/licenseText.ts | 29 + .../modrinth/src/operations/loaderList.ts | 30 + .../src/operations/modifyGalleryImage.ts | 37 + .../modrinth/src/operations/modifyProject.ts | 28 + .../modrinth/src/operations/modifyReport.ts | 28 + .../src/operations/modifyTeamMember.ts | 34 + .../modrinth/src/operations/modifyUser.ts | 42 + .../modrinth/src/operations/modifyVersion.ts | 71 + .../modrinth/src/operations/patchProjects.ts | 63 + .../src/operations/projectTypeList.ts | 26 + .../modrinth/src/operations/randomProjects.ts | 69 + .../src/operations/readNotification.ts | 26 + .../src/operations/readNotifications.ts | 28 + .../modrinth/src/operations/reportTypeList.ts | 26 + .../src/operations/scheduleProject.ts | 34 + .../src/operations/scheduleVersion.ts | 34 + .../modrinth/src/operations/searchProjects.ts | 101 + .../src/operations/sendThreadMessage.ts | 151 + .../modrinth/src/operations/sideTypeList.ts | 26 + .../modrinth/src/operations/statistics.ts | 27 + .../modrinth/src/operations/submitReport.ts | 39 + .../src/operations/transferTeamOwnership.ts | 32 + .../src/operations/unfollowProject.ts | 26 + .../src/operations/versionFromHash.ts | 85 + .../modrinth/src/operations/versionList.ts | 31 + .../src/operations/versionsFromHashes.ts | 89 + .../modrinth/src/operations/withdrawPayout.ts | 30 + packages/modrinth/src/retry.ts | 35 + packages/modrinth/src/sensitive.ts | 4 + packages/modrinth/src/traits.ts | 4 + .../modrinth/test/addFilesToVersion.test.ts | 89 + .../modrinth/test/addGalleryImage.test.ts | 100 + packages/modrinth/test/addTeamMember.test.ts | 123 + packages/modrinth/test/categoryList.test.ts | 57 + .../modrinth/test/changeProjectIcon.test.ts | 71 + packages/modrinth/test/changeUserIcon.test.ts | 80 + .../test/checkProjectValidity.test.ts | 29 + packages/modrinth/test/createProject.test.ts | 94 + packages/modrinth/test/createVersion.test.ts | 109 + .../modrinth/test/deleteFileFromHash.test.ts | 82 + .../modrinth/test/deleteGalleryImage.test.ts | 84 + .../modrinth/test/deleteNotification.test.ts | 88 + .../modrinth/test/deleteNotifications.test.ts | 93 + packages/modrinth/test/deleteProject.test.ts | 69 + .../modrinth/test/deleteProjectIcon.test.ts | 68 + .../modrinth/test/deleteTeamMember.test.ts | 129 + .../modrinth/test/deleteThreadMessage.test.ts | 124 + packages/modrinth/test/deleteUserIcon.test.ts | 80 + packages/modrinth/test/deleteVersion.test.ts | 75 + .../test/donationPlatformList.test.ts | 56 + packages/modrinth/test/followProject.test.ts | 89 + packages/modrinth/test/forgeUpdates.test.ts | 78 + .../modrinth/test/getDependencies.test.ts | 30 + .../modrinth/test/getFollowedProjects.test.ts | 81 + .../test/getLatestVersionFromHash.test.ts | 88 + .../test/getLatestVersionsFromHashes.test.ts | 128 + .../modrinth/test/getNotification.test.ts | 100 + .../modrinth/test/getNotifications.test.ts | 94 + packages/modrinth/test/getOpenReports.test.ts | 90 + .../modrinth/test/getPayoutHistory.test.ts | 73 + packages/modrinth/test/getProject.test.ts | 45 + .../test/getProjectTeamMembers.test.ts | 42 + .../modrinth/test/getProjectVersions.test.ts | 56 + packages/modrinth/test/getProjects.test.ts | 43 + packages/modrinth/test/getReport.test.ts | 101 + packages/modrinth/test/getReports.test.ts | 104 + packages/modrinth/test/getTeamMembers.test.ts | 46 + packages/modrinth/test/getTeams.test.ts | 51 + packages/modrinth/test/getThread.test.ts | 95 + packages/modrinth/test/getThreads.test.ts | 108 + packages/modrinth/test/getUser.test.ts | 48 + .../modrinth/test/getUserFromAuth.test.ts | 64 + .../test/getUserNotifications.test.ts | 84 + .../modrinth/test/getUserProjects.test.ts | 44 + packages/modrinth/test/getUsers.test.ts | 38 + packages/modrinth/test/getVersion.test.ts | 50 + .../test/getVersionFromIdOrNumber.test.ts | 67 + packages/modrinth/test/getVersions.test.ts | 43 + packages/modrinth/test/joinTeam.test.ts | 111 + packages/modrinth/test/licenseText.test.ts | 40 + packages/modrinth/test/loaderList.test.ts | 56 + .../modrinth/test/modifyGalleryImage.test.ts | 79 + packages/modrinth/test/modifyProject.test.ts | 66 + packages/modrinth/test/modifyReport.test.ts | 115 + .../modrinth/test/modifyTeamMember.test.ts | 144 + packages/modrinth/test/modifyUser.test.ts | 98 + packages/modrinth/test/modifyVersion.test.ts | 91 + packages/modrinth/test/patchProjects.test.ts | 78 + .../modrinth/test/projectTypeList.test.ts | 55 + packages/modrinth/test/randomProjects.test.ts | 33 + .../modrinth/test/readNotification.test.ts | 79 + .../modrinth/test/readNotifications.test.ts | 85 + packages/modrinth/test/reportTypeList.test.ts | 55 + .../modrinth/test/scheduleProject.test.ts | 93 + .../modrinth/test/scheduleVersion.test.ts | 93 + packages/modrinth/test/searchProjects.test.ts | 55 + .../modrinth/test/sendThreadMessage.test.ts | 138 + packages/modrinth/test/setup.ts | 28 + packages/modrinth/test/sideTypeList.test.ts | 52 + packages/modrinth/test/statistics.test.ts | 64 + packages/modrinth/test/submitReport.test.ts | 110 + .../test/transferTeamOwnership.test.ts | 149 + .../modrinth/test/unfollowProject.test.ts | 76 + .../modrinth/test/versionFromHash.test.ts | 65 + packages/modrinth/test/versionList.test.ts | 56 + .../modrinth/test/versionsFromHashes.test.ts | 94 + packages/modrinth/test/withdrawPayout.test.ts | 75 + packages/modrinth/tsconfig.json | 20 + packages/modrinth/tsconfig.test.json | 16 + packages/modrinth/vitest.config.ts | 17 + 177 files changed, 15513 insertions(+), 1 deletion(-) create mode 100644 packages/modrinth/README.md create mode 100644 packages/modrinth/package.json create mode 100644 packages/modrinth/patches/001-add-error-responses.patch.json create mode 100644 packages/modrinth/patches/002-add-mutation-bad-request.patch.json create mode 100644 packages/modrinth/scripts/generate.ts create mode 100644 packages/modrinth/scripts/nuke.ts create mode 100644 packages/modrinth/specs/openapi.yaml create mode 100644 packages/modrinth/src/category.ts create mode 100644 packages/modrinth/src/client.ts create mode 100644 packages/modrinth/src/credentials.ts create mode 100644 packages/modrinth/src/errors.ts create mode 100644 packages/modrinth/src/index.ts create mode 100644 packages/modrinth/src/operations/addFilesToVersion.ts create mode 100644 packages/modrinth/src/operations/addGalleryImage.ts create mode 100644 packages/modrinth/src/operations/addTeamMember.ts create mode 100644 packages/modrinth/src/operations/categoryList.ts create mode 100644 packages/modrinth/src/operations/changeProjectIcon.ts create mode 100644 packages/modrinth/src/operations/changeUserIcon.ts create mode 100644 packages/modrinth/src/operations/checkProjectValidity.ts create mode 100644 packages/modrinth/src/operations/createProject.ts create mode 100644 packages/modrinth/src/operations/createVersion.ts create mode 100644 packages/modrinth/src/operations/deleteFileFromHash.ts create mode 100644 packages/modrinth/src/operations/deleteGalleryImage.ts create mode 100644 packages/modrinth/src/operations/deleteNotification.ts create mode 100644 packages/modrinth/src/operations/deleteNotifications.ts create mode 100644 packages/modrinth/src/operations/deleteProject.ts create mode 100644 packages/modrinth/src/operations/deleteProjectIcon.ts create mode 100644 packages/modrinth/src/operations/deleteTeamMember.ts create mode 100644 packages/modrinth/src/operations/deleteThreadMessage.ts create mode 100644 packages/modrinth/src/operations/deleteUserIcon.ts create mode 100644 packages/modrinth/src/operations/deleteVersion.ts create mode 100644 packages/modrinth/src/operations/donationPlatformList.ts create mode 100644 packages/modrinth/src/operations/followProject.ts create mode 100644 packages/modrinth/src/operations/forgeUpdates.ts create mode 100644 packages/modrinth/src/operations/getDependencies.ts create mode 100644 packages/modrinth/src/operations/getFollowedProjects.ts create mode 100644 packages/modrinth/src/operations/getLatestVersionFromHash.ts create mode 100644 packages/modrinth/src/operations/getLatestVersionsFromHashes.ts create mode 100644 packages/modrinth/src/operations/getNotification.ts create mode 100644 packages/modrinth/src/operations/getNotifications.ts create mode 100644 packages/modrinth/src/operations/getOpenReports.ts create mode 100644 packages/modrinth/src/operations/getPayoutHistory.ts create mode 100644 packages/modrinth/src/operations/getProject.ts create mode 100644 packages/modrinth/src/operations/getProjectTeamMembers.ts create mode 100644 packages/modrinth/src/operations/getProjectVersions.ts create mode 100644 packages/modrinth/src/operations/getProjects.ts create mode 100644 packages/modrinth/src/operations/getReport.ts create mode 100644 packages/modrinth/src/operations/getReports.ts create mode 100644 packages/modrinth/src/operations/getTeamMembers.ts create mode 100644 packages/modrinth/src/operations/getTeams.ts create mode 100644 packages/modrinth/src/operations/getThread.ts create mode 100644 packages/modrinth/src/operations/getThreads.ts create mode 100644 packages/modrinth/src/operations/getUser.ts create mode 100644 packages/modrinth/src/operations/getUserFromAuth.ts create mode 100644 packages/modrinth/src/operations/getUserNotifications.ts create mode 100644 packages/modrinth/src/operations/getUserProjects.ts create mode 100644 packages/modrinth/src/operations/getUsers.ts create mode 100644 packages/modrinth/src/operations/getVersion.ts create mode 100644 packages/modrinth/src/operations/getVersionFromIdOrNumber.ts create mode 100644 packages/modrinth/src/operations/getVersions.ts create mode 100644 packages/modrinth/src/operations/index.ts create mode 100644 packages/modrinth/src/operations/joinTeam.ts create mode 100644 packages/modrinth/src/operations/licenseText.ts create mode 100644 packages/modrinth/src/operations/loaderList.ts create mode 100644 packages/modrinth/src/operations/modifyGalleryImage.ts create mode 100644 packages/modrinth/src/operations/modifyProject.ts create mode 100644 packages/modrinth/src/operations/modifyReport.ts create mode 100644 packages/modrinth/src/operations/modifyTeamMember.ts create mode 100644 packages/modrinth/src/operations/modifyUser.ts create mode 100644 packages/modrinth/src/operations/modifyVersion.ts create mode 100644 packages/modrinth/src/operations/patchProjects.ts create mode 100644 packages/modrinth/src/operations/projectTypeList.ts create mode 100644 packages/modrinth/src/operations/randomProjects.ts create mode 100644 packages/modrinth/src/operations/readNotification.ts create mode 100644 packages/modrinth/src/operations/readNotifications.ts create mode 100644 packages/modrinth/src/operations/reportTypeList.ts create mode 100644 packages/modrinth/src/operations/scheduleProject.ts create mode 100644 packages/modrinth/src/operations/scheduleVersion.ts create mode 100644 packages/modrinth/src/operations/searchProjects.ts create mode 100644 packages/modrinth/src/operations/sendThreadMessage.ts create mode 100644 packages/modrinth/src/operations/sideTypeList.ts create mode 100644 packages/modrinth/src/operations/statistics.ts create mode 100644 packages/modrinth/src/operations/submitReport.ts create mode 100644 packages/modrinth/src/operations/transferTeamOwnership.ts create mode 100644 packages/modrinth/src/operations/unfollowProject.ts create mode 100644 packages/modrinth/src/operations/versionFromHash.ts create mode 100644 packages/modrinth/src/operations/versionList.ts create mode 100644 packages/modrinth/src/operations/versionsFromHashes.ts create mode 100644 packages/modrinth/src/operations/withdrawPayout.ts create mode 100644 packages/modrinth/src/retry.ts create mode 100644 packages/modrinth/src/sensitive.ts create mode 100644 packages/modrinth/src/traits.ts create mode 100644 packages/modrinth/test/addFilesToVersion.test.ts create mode 100644 packages/modrinth/test/addGalleryImage.test.ts create mode 100644 packages/modrinth/test/addTeamMember.test.ts create mode 100644 packages/modrinth/test/categoryList.test.ts create mode 100644 packages/modrinth/test/changeProjectIcon.test.ts create mode 100644 packages/modrinth/test/changeUserIcon.test.ts create mode 100644 packages/modrinth/test/checkProjectValidity.test.ts create mode 100644 packages/modrinth/test/createProject.test.ts create mode 100644 packages/modrinth/test/createVersion.test.ts create mode 100644 packages/modrinth/test/deleteFileFromHash.test.ts create mode 100644 packages/modrinth/test/deleteGalleryImage.test.ts create mode 100644 packages/modrinth/test/deleteNotification.test.ts create mode 100644 packages/modrinth/test/deleteNotifications.test.ts create mode 100644 packages/modrinth/test/deleteProject.test.ts create mode 100644 packages/modrinth/test/deleteProjectIcon.test.ts create mode 100644 packages/modrinth/test/deleteTeamMember.test.ts create mode 100644 packages/modrinth/test/deleteThreadMessage.test.ts create mode 100644 packages/modrinth/test/deleteUserIcon.test.ts create mode 100644 packages/modrinth/test/deleteVersion.test.ts create mode 100644 packages/modrinth/test/donationPlatformList.test.ts create mode 100644 packages/modrinth/test/followProject.test.ts create mode 100644 packages/modrinth/test/forgeUpdates.test.ts create mode 100644 packages/modrinth/test/getDependencies.test.ts create mode 100644 packages/modrinth/test/getFollowedProjects.test.ts create mode 100644 packages/modrinth/test/getLatestVersionFromHash.test.ts create mode 100644 packages/modrinth/test/getLatestVersionsFromHashes.test.ts create mode 100644 packages/modrinth/test/getNotification.test.ts create mode 100644 packages/modrinth/test/getNotifications.test.ts create mode 100644 packages/modrinth/test/getOpenReports.test.ts create mode 100644 packages/modrinth/test/getPayoutHistory.test.ts create mode 100644 packages/modrinth/test/getProject.test.ts create mode 100644 packages/modrinth/test/getProjectTeamMembers.test.ts create mode 100644 packages/modrinth/test/getProjectVersions.test.ts create mode 100644 packages/modrinth/test/getProjects.test.ts create mode 100644 packages/modrinth/test/getReport.test.ts create mode 100644 packages/modrinth/test/getReports.test.ts create mode 100644 packages/modrinth/test/getTeamMembers.test.ts create mode 100644 packages/modrinth/test/getTeams.test.ts create mode 100644 packages/modrinth/test/getThread.test.ts create mode 100644 packages/modrinth/test/getThreads.test.ts create mode 100644 packages/modrinth/test/getUser.test.ts create mode 100644 packages/modrinth/test/getUserFromAuth.test.ts create mode 100644 packages/modrinth/test/getUserNotifications.test.ts create mode 100644 packages/modrinth/test/getUserProjects.test.ts create mode 100644 packages/modrinth/test/getUsers.test.ts create mode 100644 packages/modrinth/test/getVersion.test.ts create mode 100644 packages/modrinth/test/getVersionFromIdOrNumber.test.ts create mode 100644 packages/modrinth/test/getVersions.test.ts create mode 100644 packages/modrinth/test/joinTeam.test.ts create mode 100644 packages/modrinth/test/licenseText.test.ts create mode 100644 packages/modrinth/test/loaderList.test.ts create mode 100644 packages/modrinth/test/modifyGalleryImage.test.ts create mode 100644 packages/modrinth/test/modifyProject.test.ts create mode 100644 packages/modrinth/test/modifyReport.test.ts create mode 100644 packages/modrinth/test/modifyTeamMember.test.ts create mode 100644 packages/modrinth/test/modifyUser.test.ts create mode 100644 packages/modrinth/test/modifyVersion.test.ts create mode 100644 packages/modrinth/test/patchProjects.test.ts create mode 100644 packages/modrinth/test/projectTypeList.test.ts create mode 100644 packages/modrinth/test/randomProjects.test.ts create mode 100644 packages/modrinth/test/readNotification.test.ts create mode 100644 packages/modrinth/test/readNotifications.test.ts create mode 100644 packages/modrinth/test/reportTypeList.test.ts create mode 100644 packages/modrinth/test/scheduleProject.test.ts create mode 100644 packages/modrinth/test/scheduleVersion.test.ts create mode 100644 packages/modrinth/test/searchProjects.test.ts create mode 100644 packages/modrinth/test/sendThreadMessage.test.ts create mode 100644 packages/modrinth/test/setup.ts create mode 100644 packages/modrinth/test/sideTypeList.test.ts create mode 100644 packages/modrinth/test/statistics.test.ts create mode 100644 packages/modrinth/test/submitReport.test.ts create mode 100644 packages/modrinth/test/transferTeamOwnership.test.ts create mode 100644 packages/modrinth/test/unfollowProject.test.ts create mode 100644 packages/modrinth/test/versionFromHash.test.ts create mode 100644 packages/modrinth/test/versionList.test.ts create mode 100644 packages/modrinth/test/versionsFromHashes.test.ts create mode 100644 packages/modrinth/test/withdrawPayout.test.ts create mode 100644 packages/modrinth/tsconfig.json create mode 100644 packages/modrinth/tsconfig.test.json create mode 100644 packages/modrinth/vitest.config.ts diff --git a/.github/workflows/nuke.yml b/.github/workflows/nuke.yml index 9a26d96d8..d31ac7612 100644 --- a/.github/workflows/nuke.yml +++ b/.github/workflows/nuke.yml @@ -70,6 +70,10 @@ on: description: "Eas" type: boolean default: false + modrinth: + description: "Modrinth" + type: boolean + default: false env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" diff --git a/.github/workflows/pr-package.yml b/.github/workflows/pr-package.yml index d2c6d918a..1f4886f45 100644 --- a/.github/workflows/pr-package.yml +++ b/.github/workflows/pr-package.yml @@ -36,7 +36,7 @@ jobs: if: contains(github.event.pull_request.labels.*.name, 'force-ci') run: | set -euo pipefail - all='["core","aws","cloudflare","gcp","neon","planetscale","prisma-postgres","stripe","supabase","posthog","axiom","azure","kubernetes","coinbase","mongodb-atlas","fly-io","turso","typesense","workos","expo-eas"]' + all='["core","aws","cloudflare","gcp","neon","planetscale","prisma-postgres","stripe","supabase","posthog","axiom","azure","kubernetes","coinbase","mongodb-atlas","fly-io","turso","typesense","workos","expo-eas","modrinth"]' echo "packages=${all}" >> "$GITHUB_OUTPUT" - uses: actions/checkout@v6 - uses: dorny/paths-filter@v4 @@ -102,6 +102,9 @@ jobs: expo-eas: - 'packages/expo-eas/**' - 'packages/core/**' + modrinth: + - 'packages/modrinth/**' + - 'packages/core/**' # ── Compute tags once so every matrix job + the comment use the same set. ─ tags: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f7bf1d386..932b373f9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,6 +46,7 @@ env: packages/typesense/package.json packages/workos/package.json packages/expo-eas/package.json + packages/modrinth/package.json bun.lock CHANGELOG.md diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 30c3bc584..5bf588ae0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,6 +42,7 @@ jobs: typesense: ${{ steps.force.outputs.all || steps.changes.outputs.typesense }} workos: ${{ steps.force.outputs.all || steps.changes.outputs.workos }} expo-eas: ${{ steps.force.outputs.all || steps.changes.outputs.expo-eas }} + modrinth: ${{ steps.force.outputs.all || steps.changes.outputs.modrinth }} steps: - id: force if: contains(github.event.pull_request.labels.*.name, 'force-ci') @@ -110,6 +111,9 @@ jobs: expo-eas: - 'packages/expo-eas/**' - 'packages/core/**' + modrinth: + - 'packages/modrinth/**' + - 'packages/core/**' ci-core: needs: detect-changes @@ -494,3 +498,24 @@ jobs: working-directory: packages/expo-eas env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + + ci-modrinth: + needs: detect-changes + if: needs.detect-changes.outputs.modrinth == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: bun install + - run: bun run build + working-directory: packages/core + - run: bun run check + working-directory: packages/modrinth + - run: bun run test + working-directory: packages/modrinth + env: + MODRINTH_API_BASE_URL: ${{ secrets.MODRINTH_API_BASE_URL }} + MODRINTH_API_KEY: ${{ secrets.MODRINTH_API_KEY }} + MODRINTH_USER_AGENT: ${{ secrets.MODRINTH_USER_AGENT }} diff --git a/bun.lock b/bun.lock index 8c157a6a9..9703de61f 100644 --- a/bun.lock +++ b/bun.lock @@ -197,6 +197,24 @@ "effect": "catalog:", }, }, + "packages/modrinth": { + "name": "@distilled.cloud/modrinth", + "version": "0.2.0-alpha", + "dependencies": { + "@distilled.cloud/core": "workspace:*", + "effect": "catalog:", + }, + "devDependencies": { + "@types/bun": "catalog:", + "@types/node": "catalog:", + "dotenv": "catalog:", + "vitest": "catalog:", + "yaml": "catalog:", + }, + "peerDependencies": { + "effect": "catalog:", + }, + }, "packages/mongodb-atlas": { "name": "@distilled.cloud/mongodb-atlas", "version": "0.19.0", @@ -488,6 +506,8 @@ "@distilled.cloud/kubernetes": ["@distilled.cloud/kubernetes@workspace:packages/kubernetes"], + "@distilled.cloud/modrinth": ["@distilled.cloud/modrinth@workspace:packages/modrinth"], + "@distilled.cloud/mongodb-atlas": ["@distilled.cloud/mongodb-atlas@workspace:packages/mongodb-atlas"], "@distilled.cloud/neon": ["@distilled.cloud/neon@workspace:packages/neon"], diff --git a/packages/modrinth/README.md b/packages/modrinth/README.md new file mode 100644 index 000000000..7c1180c57 --- /dev/null +++ b/packages/modrinth/README.md @@ -0,0 +1,79 @@ +# @distilled.cloud/modrinth + +Effect-native Modrinth SDK generated from the [Modrinth OpenAPI specification](https://docs.modrinth.com/openapi.yaml). Browse, create, and manage projects, versions, users, teams, threads, and notifications on the Modrinth platform with exhaustive error typing. + +## Installation + +```bash +npm install @distilled.cloud/modrinth effect +``` + +## Quick Start + +```typescript +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { searchProjects } from "@distilled.cloud/modrinth/Operations"; +import { CredentialsFromEnv } from "@distilled.cloud/modrinth"; + +const program = Effect.gen(function* () { + const results = yield* searchProjects({ + query: "fabric api", + limit: 10, + }); + return results.hits; +}); + +const ModrinthLive = Layer.mergeAll(FetchHttpClient.layer, CredentialsFromEnv); + +program.pipe(Effect.provide(ModrinthLive), Effect.runPromise); +``` + +## Configuration + +```bash +# Optional — required only for endpoints that create/modify data or access +# private resources. Most read endpoints work without a token. +MODRINTH_API_KEY=mrp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# Strongly recommended — Modrinth requires a uniquely-identifying User-Agent +# header and may throttle traffic that only identifies the HTTP client. +# Format: // () +MODRINTH_USER_AGENT="my-org/my-app/1.0.0 (contact@example.com)" + +# Optional — defaults to https://api.modrinth.com/v2. +# Use https://staging-api.modrinth.com/v2 to target the staging environment. +MODRINTH_API_BASE_URL=https://api.modrinth.com/v2 +``` + +Personal access tokens are generated in [Modrinth account settings](https://modrinth.com/settings/account). Tokens are sent verbatim in the `Authorization` header (no `Bearer ` prefix) and are scoped — see the [scopes list](https://github.com/modrinth/labrinth/blob/master/src/models/pats.rs) for details. + +## Error Handling + +```typescript +import { getProject } from "@distilled.cloud/modrinth/Operations"; +import { Effect } from "effect"; + +getProject({ "id|slug": "missing-project" }).pipe( + Effect.catchTags({ + NotFound: () => Effect.succeed(null), + Unauthorized: (e) => Effect.fail(new Error(`Auth: ${e.message}`)), + UnknownModrinthError: (e) => Effect.fail(new Error(`Unknown: ${e.message}`)), + }), +); +``` + +## Services + +- **Projects** — `searchProjects`, `getProject`, `getProjects`, `createProject`, `modifyProject`, `deleteProject`, `randomProjects`, `checkProjectValidity`, `changeProjectIcon`, `deleteProjectIcon`, `addGalleryImage`, `modifyGalleryImage`, `deleteGalleryImage`, `getDependencies`, `followProject`, `unfollowProject`, `scheduleProject` +- **Versions** — `getProjectVersions`, `getVersion`, `getVersions`, `createVersion`, `modifyVersion`, `deleteVersion`, `scheduleVersion`, `getVersionFromIdOrNumber`, `addFilesToVersion`, `versionFromHash`, `versionsFromHashes`, `deleteFileFromHash`, `getLatestVersionFromHash`, `getLatestVersionsFromHashes` +- **Users** — `getUser`, `getUsers`, `getUserFromAuth`, `modifyUser`, `changeUserIcon`, `deleteUserIcon`, `getUserProjects`, `getFollowedProjects`, `getPayoutHistory`, `withdrawPayout` +- **Notifications** — `getUserNotifications`, `getNotification`, `getNotifications`, `readNotification`, `readNotifications`, `deleteNotification`, `deleteNotifications` +- **Teams** — `getProjectTeamMembers`, `getTeamMembers`, `getTeams`, `addTeamMember`, `joinTeam`, `modifyTeamMember`, `deleteTeamMember`, `transferTeamOwnership` +- **Threads & Reports** — `getThread`, `getThreads`, `sendThreadMessage`, `deleteThreadMessage`, `submitReport`, `getReport`, `getReports`, `getOpenReports`, `modifyReport` +- **Tags** — `categoryList`, `loaderList`, `versionList`, `licenseText`, `donationPlatformList`, `reportTypeList`, `projectTypeList`, `sideTypeList` +- **Misc** — `forgeUpdates`, `statistics` + +## License + +MIT diff --git a/packages/modrinth/package.json b/packages/modrinth/package.json new file mode 100644 index 000000000..cc02d6237 --- /dev/null +++ b/packages/modrinth/package.json @@ -0,0 +1,89 @@ +{ + "name": "@distilled.cloud/modrinth", + "version": "0.2.0-alpha", + "repository": { + "type": "git", + "url": "https://github.com/alchemy-run/distilled", + "directory": "packages/modrinth" + }, + "type": "module", + "sideEffects": false, + "module": "src/index.ts", + "files": [ + "lib", + "src" + ], + "exports": { + ".": { + "types": "./lib/index.d.ts", + "bun": "./src/index.ts", + "default": "./lib/index.js" + }, + "./Category": { + "types": "./lib/category.d.ts", + "bun": "./src/category.ts", + "default": "./lib/category.js" + }, + "./Client": { + "types": "./lib/client.d.ts", + "bun": "./src/client.ts", + "default": "./lib/client.js" + }, + "./Credentials": { + "types": "./lib/credentials.d.ts", + "bun": "./src/credentials.ts", + "default": "./lib/credentials.js" + }, + "./Errors": { + "types": "./lib/errors.d.ts", + "bun": "./src/errors.ts", + "default": "./lib/errors.js" + }, + "./Operations": { + "types": "./lib/operations/index.d.ts", + "bun": "./src/operations/index.ts", + "default": "./lib/operations/index.js" + }, + "./Retry": { + "types": "./lib/retry.d.ts", + "bun": "./src/retry.ts", + "default": "./lib/retry.js" + }, + "./Sensitive": { + "types": "./lib/sensitive.d.ts", + "bun": "./src/sensitive.ts", + "default": "./lib/sensitive.js" + }, + "./Traits": { + "types": "./lib/traits.d.ts", + "bun": "./src/traits.ts", + "default": "./lib/traits.js" + } + }, + "scripts": { + "typecheck": "tsgo", + "build": "tsgo -b", + "fmt": "oxfmt --write src", + "lint": "oxlint --fix src", + "check": "tsgo && oxlint src && oxfmt --check src", + "test": "bunx vitest run test --passWithNoTests", + "publish:npm": "bun run build && bun publish --access public", + "generate": "bun run scripts/generate.ts && oxlint --fix src && oxfmt --write src && oxfmt --write src", + "specs:fetch": "echo 'No specs configured'", + "specs:update": "echo 'No specs configured'" + }, + "dependencies": { + "@distilled.cloud/core": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@types/bun": "catalog:", + "@types/node": "catalog:", + "dotenv": "catalog:", + "vitest": "catalog:", + "yaml": "catalog:" + }, + "peerDependencies": { + "effect": "catalog:" + } +} \ No newline at end of file diff --git a/packages/modrinth/patches/001-add-error-responses.patch.json b/packages/modrinth/patches/001-add-error-responses.patch.json new file mode 100644 index 000000000..1de48114a --- /dev/null +++ b/packages/modrinth/patches/001-add-error-responses.patch.json @@ -0,0 +1,113 @@ +{ + "description": "Add 400 BadRequest responses to operations that return them in practice but don't document them. Discovered via live API probing — Modrinth returns 400 with `{ error: 'invalid_input' | 'json_error', description }` for malformed base62 IDs (path params) and malformed JSON arrays (query params), but the spec omits these responses on the corresponding operations.", + "patches": [ + { + "op": "add", + "path": "/paths/~1version~1{id}/get/responses/400", + "value": { + "description": "Path parameter could not be parsed (e.g. invalid base62 encoding)", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvalidInputError" } + } + } + } + }, + { + "op": "add", + "path": "/paths/~1versions/get/responses/400", + "value": { + "description": "Query parameter `ids` could not be parsed as a JSON array of strings", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvalidInputError" } + } + } + } + }, + { + "op": "add", + "path": "/paths/~1users/get/responses/400", + "value": { + "description": "Query parameter `ids` could not be parsed as a JSON array of strings", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvalidInputError" } + } + } + } + }, + { + "op": "add", + "path": "/paths/~1teams/get/responses/400", + "value": { + "description": "Query parameter `ids` could not be parsed as a JSON array of strings", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvalidInputError" } + } + } + } + }, + { + "op": "add", + "path": "/paths/~1team~1{id}~1members/get/responses/400", + "value": { + "description": "Path parameter could not be parsed (e.g. invalid base62 encoding)", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvalidInputError" } + } + } + } + }, + { + "op": "add", + "path": "/paths/~1report~1{id}/get/responses/400", + "value": { + "description": "Path parameter could not be parsed (e.g. invalid base62 encoding)", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvalidInputError" } + } + } + } + }, + { + "op": "add", + "path": "/paths/~1reports/get/responses/400", + "value": { + "description": "Query parameter `ids` missing or could not be parsed as a JSON array of strings", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvalidInputError" } + } + } + } + }, + { + "op": "add", + "path": "/paths/~1notification~1{id}/get/responses/400", + "value": { + "description": "Path parameter could not be parsed (e.g. invalid base62 encoding)", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvalidInputError" } + } + } + } + }, + { + "op": "add", + "path": "/paths/~1thread~1{id}/get/responses/400", + "value": { + "description": "Path parameter could not be parsed (e.g. invalid base62 encoding)", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvalidInputError" } + } + } + } + } + ] +} diff --git a/packages/modrinth/patches/002-add-mutation-bad-request.patch.json b/packages/modrinth/patches/002-add-mutation-bad-request.patch.json new file mode 100644 index 000000000..a3928a107 --- /dev/null +++ b/packages/modrinth/patches/002-add-mutation-bad-request.patch.json @@ -0,0 +1,221 @@ +{ + "description": "Add 400 BadRequest responses to mutating (PATCH/POST/DELETE) operations and a few read endpoints whose query parsers raise 400. The Modrinth spec only documents 401/404 for these, but live probing shows the API consistently returns `{ error: 'invalid_input', description: ... }` with HTTP 400 for: malformed JSON bodies, missing/non-JSON `ids` query params, malformed multipart payloads, and base62 path parsing failures. Discovered via live API probing 2026-05-08.", + "patches": [ + { + "op": "add", + "path": "/paths/~1project~1{id_or_slug}/patch/responses/400", + "value": { + "description": "Request body could not be parsed (e.g. malformed JSON)", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvalidInputError" } + } + } + } + }, + { + "op": "add", + "path": "/paths/~1project~1{id_or_slug}~1gallery/patch/responses/400", + "value": { + "description": "Query parameters could not be parsed (e.g. missing required `url`)", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvalidInputError" } + } + } + } + }, + { + "op": "add", + "path": "/paths/~1version~1{id}/patch/responses/400", + "value": { + "description": "Request body could not be parsed (e.g. malformed JSON)", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvalidInputError" } + } + } + } + }, + { + "op": "add", + "path": "/paths/~1version~1{id}/delete/responses/400", + "value": { + "description": "Path parameter could not be parsed (e.g. invalid base62 encoding)", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvalidInputError" } + } + } + } + }, + { + "op": "add", + "path": "/paths/~1version~1{id}~1file/post/responses/400", + "value": { + "description": "Path parameter could not be parsed (e.g. invalid base62 encoding) or multipart body invalid", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvalidInputError" } + } + } + } + }, + { + "op": "add", + "path": "/paths/~1user~1{id_or_username}/patch/responses/400", + "value": { + "description": "Request body could not be parsed (e.g. malformed JSON)", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvalidInputError" } + } + } + } + }, + { + "op": "add", + "path": "/paths/~1notification~1{id}/patch/responses/400", + "value": { + "description": "Path parameter could not be parsed (e.g. invalid base62 encoding)", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvalidInputError" } + } + } + } + }, + { + "op": "add", + "path": "/paths/~1notification~1{id}/delete/responses/400", + "value": { + "description": "Path parameter could not be parsed (e.g. invalid base62 encoding)", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvalidInputError" } + } + } + } + }, + { + "op": "add", + "path": "/paths/~1notifications/get/responses/400", + "value": { + "description": "Query parameter `ids` missing or could not be parsed as a JSON array of strings", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvalidInputError" } + } + } + } + }, + { + "op": "add", + "path": "/paths/~1notifications/patch/responses/400", + "value": { + "description": "Query parameter `ids` missing or could not be parsed as a JSON array of strings", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvalidInputError" } + } + } + } + }, + { + "op": "add", + "path": "/paths/~1notifications/delete/responses/400", + "value": { + "description": "Query parameter `ids` missing or could not be parsed as a JSON array of strings", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvalidInputError" } + } + } + } + }, + { + "op": "add", + "path": "/paths/~1threads/get/responses/400", + "value": { + "description": "Query parameter `ids` missing or could not be parsed as a JSON array of strings", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvalidInputError" } + } + } + } + }, + { + "op": "add", + "path": "/paths/~1message~1{id}/delete/responses/400", + "value": { + "description": "Path parameter could not be parsed (e.g. invalid base62 encoding)", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvalidInputError" } + } + } + } + }, + { + "op": "add", + "path": "/paths/~1team~1{id}~1members/post/responses/400", + "value": { + "description": "Path parameter could not be parsed (e.g. invalid base62 encoding) or request body invalid", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvalidInputError" } + } + } + } + }, + { + "op": "add", + "path": "/paths/~1team~1{id}~1join/post/responses/400", + "value": { + "description": "Path parameter could not be parsed (e.g. invalid base62 encoding)", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvalidInputError" } + } + } + } + }, + { + "op": "add", + "path": "/paths/~1team~1{id}~1members~1{id_or_username}/patch/responses/400", + "value": { + "description": "Request body could not be parsed (e.g. malformed JSON) or path parameter invalid", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvalidInputError" } + } + } + } + }, + { + "op": "add", + "path": "/paths/~1team~1{id}~1members~1{id_or_username}/delete/responses/400", + "value": { + "description": "Path parameter could not be parsed (e.g. invalid base62 encoding)", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvalidInputError" } + } + } + } + }, + { + "op": "add", + "path": "/paths/~1team~1{id}~1owner/patch/responses/400", + "value": { + "description": "Request body could not be parsed (e.g. malformed JSON)", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvalidInputError" } + } + } + } + } + ] +} diff --git a/packages/modrinth/scripts/generate.ts b/packages/modrinth/scripts/generate.ts new file mode 100644 index 000000000..1c0e0031b --- /dev/null +++ b/packages/modrinth/scripts/generate.ts @@ -0,0 +1,73 @@ +/** + * Modrinth SDK Code Generator + * + * Modrinth publishes its OpenAPI 3.0 spec at https://docs.modrinth.com/openapi.yaml. + * There is no git submodule for this SDK — the spec is fetched on demand and + * cached to specs/openapi.yaml. The YAML is converted to JSON in-memory before + * being handed to the shared OpenAPI generator (which only consumes JSON). + */ +import * as fs from "fs"; +import * as path from "path"; +import YAML from "yaml"; +import { generateFromOpenAPI } from "@distilled.cloud/core/openapi/generate"; + +const SPEC_URL = "https://docs.modrinth.com/openapi.yaml"; + +const rootDir = path.join(import.meta.dir, ".."); +const specsDir = path.join(rootDir, "specs"); +const yamlPath = path.join(specsDir, "openapi.yaml"); +const jsonPath = path.join(specsDir, "openapi.json"); + +if (!fs.existsSync(specsDir)) { + fs.mkdirSync(specsDir, { recursive: true }); +} + +async function fetchSpec(): Promise { + const res = await fetch(SPEC_URL); + if (!res.ok) { + throw new Error( + `Failed to fetch Modrinth OpenAPI spec from ${SPEC_URL}: ${res.status} ${res.statusText}`, + ); + } + return await res.text(); +} + +const yamlContent = process.env.MODRINTH_SPEC_OFFLINE && fs.existsSync(yamlPath) + ? fs.readFileSync(yamlPath, "utf-8") + : await fetchSpec(); + +fs.writeFileSync(yamlPath, yamlContent); + +// Modrinth's spec uses compound path parameter names like `id|slug`, +// `id|username`, and `id|number` to signal "either an ID or a human-readable +// alias". Pipe characters are not valid in JS identifiers, so the generated +// Schema.Struct property keys come out unquoted and break TypeScript parsing. +// Rename them to JS-safe equivalents before generation. Every occurrence in +// the spec is either a `name:` parameter declaration or a `{...}` path +// template — there are no prose mentions, so a string replace is safe. +const normalizedYaml = yamlContent + .replaceAll("id|slug", "id_or_slug") + .replaceAll("id|username", "id_or_username") + .replaceAll("id|number", "id_or_number"); + +const spec = YAML.parse(normalizedYaml); +fs.writeFileSync(jsonPath, JSON.stringify(spec, null, 2)); + +try { + generateFromOpenAPI({ + specPath: jsonPath, + patchDir: path.join(rootDir, "patches"), + outputDir: path.join(rootDir, "src/operations"), + importPrefix: "..", + clientImport: "../client", + traitsImport: "../traits", + sensitiveImport: "../sensitive", + errorsImport: "../errors", + includeOperationErrors: true, + skipDeprecated: true, + }); +} finally { + if (fs.existsSync(jsonPath)) { + fs.unlinkSync(jsonPath); + } +} diff --git a/packages/modrinth/scripts/nuke.ts b/packages/modrinth/scripts/nuke.ts new file mode 100644 index 000000000..98737876d --- /dev/null +++ b/packages/modrinth/scripts/nuke.ts @@ -0,0 +1,475 @@ +#!/usr/bin/env bun +/** + * Modrinth Nuke Script + * + * Lists and deletes all resources owned by the authenticated Modrinth user. + * Supports --dry-run to preview without deleting. + * + * Resources nuked (in dependency order): + * 1. Versions — child of Project; deleted first so projects with + * external dependents can be removed cleanly. + * 2. Projects — owned by the authed user (DELETE /project/{id_or_slug}). + * 3. FollowedProject — unfollow each followed project. + * 4. Notifications — delete each notification on the authed user. + * + * Usage: + * bun packages/modrinth/scripts/nuke.ts --dry-run + * bun packages/modrinth/scripts/nuke.ts + */ +import { config } from "dotenv"; +import * as fs from "node:fs"; +import * as nodePath from "node:path"; + +// Load .env from repo root (three levels up from scripts/), then CWD as fallback. +const envPath = nodePath.resolve(import.meta.dir, "../../../.env"); +config({ path: envPath }); +if (!process.env.MODRINTH_API_KEY) { + config(); +} + +import { BunRuntime, BunServices } from "@effect/platform-bun"; +import { Console, Effect } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { Command, Flag } from "effect/unstable/cli"; +import { CredentialsFromEnv } from "../src/credentials.ts"; +import { getUserFromAuth } from "../src/operations/getUserFromAuth.ts"; +import { getUserProjects } from "../src/operations/getUserProjects.ts"; +import { getProjectVersions } from "../src/operations/getProjectVersions.ts"; +import { deleteVersion } from "../src/operations/deleteVersion.ts"; +import { deleteProject } from "../src/operations/deleteProject.ts"; +import { getFollowedProjects } from "../src/operations/getFollowedProjects.ts"; +import { unfollowProject } from "../src/operations/unfollowProject.ts"; +import { getUserNotifications } from "../src/operations/getUserNotifications.ts"; +import { deleteNotification } from "../src/operations/deleteNotification.ts"; + +// ANSI colors +const RED = "\x1b[31m"; +const GREEN = "\x1b[32m"; +const YELLOW = "\x1b[33m"; +const CYAN = "\x1b[36m"; +const BOLD = "\x1b[1m"; +const DIM = "\x1b[2m"; +const RESET = "\x1b[0m"; + +// Counters +let totalFound = 0; +let totalSkipped = 0; +let totalDeleted = 0; +let totalFailed = 0; + +// ============================================================================ +// Nuke Config +// ============================================================================ + +interface ExcludeRule { + type: string; + ids?: string[]; + namePatterns?: string[]; + reason?: string; +} + +interface NukeConfig { + exclude?: ExcludeRule[]; +} + +const PKG_DIR = nodePath.resolve(import.meta.dir, ".."); + +function loadNukeConfig(): NukeConfig { + const p = nodePath.join(PKG_DIR, "nuke-config.json"); + if (!fs.existsSync(p)) return {}; + return JSON.parse(fs.readFileSync(p, "utf-8")); +} + +function matchGlob(pattern: string, value: string): boolean { + return new RegExp("^" + pattern.replace(/\*/g, ".*") + "$").test(value); +} + +function isExcluded( + config: NukeConfig, + type: string, + id: string, + name?: string, +): ExcludeRule | undefined { + return config.exclude?.find((rule) => { + if (rule.type !== type) return false; + if (rule.ids?.includes(id)) return true; + if (name && rule.namePatterns?.some((p) => matchGlob(p, name))) return true; + return false; + }); +} + +// ============================================================================ +// Resource operations +// ============================================================================ + +const nukeProjectVersions = ( + dryRun: boolean, + nukeConfig: NukeConfig, + projectId: string, + projectSlug: string, +) => + Effect.gen(function* () { + const versions = yield* getProjectVersions({ id_or_slug: projectId }).pipe( + Effect.catch(() => + Console.log( + ` ${RED}Failed to list versions for project ${projectSlug}${RESET}`, + ).pipe(Effect.map(() => [] as any[])), + ), + ); + + if (versions.length === 0) { + yield* Console.log(` ${DIM}No versions${RESET}`); + return; + } + + for (const version of versions) { + const versionId = version.id ?? "unknown"; + const versionName = version.version_number ?? version.name ?? "unknown"; + totalFound++; + + const excluded = isExcluded( + nukeConfig, + "Version", + versionId, + versionName, + ); + if (excluded) { + totalSkipped++; + yield* Console.log( + ` ${YELLOW}[SKIP]${RESET} Version: ${versionName} ${DIM}(id: ${versionId}, project: ${projectSlug})${RESET} — ${excluded.reason ?? "excluded"}`, + ); + continue; + } + + if (dryRun) { + yield* Console.log( + ` ${RED}[DELETE]${RESET} Version: ${versionName} ${DIM}(id: ${versionId}, status: ${version.status ?? "unknown"})${RESET}`, + ); + } else { + yield* Console.log( + ` ${RED}[DELETE]${RESET} Version: ${versionName} ${DIM}(id: ${versionId})${RESET}`, + ); + yield* deleteVersion({ id: versionId }).pipe( + Effect.andThen(() => { + totalDeleted++; + }), + Effect.catch((err) => { + totalFailed++; + return Console.log( + ` ${RED}Failed to delete version ${versionId}: ${(err as any)?._tag ?? "unknown"}${RESET}`, + ); + }), + ); + } + } + }); + +const nukeProjects = ( + dryRun: boolean, + nukeConfig: NukeConfig, + userId: string, +) => + Effect.gen(function* () { + yield* Console.log(`\n${BOLD}${CYAN}Projects${RESET}`); + + const projects = yield* getUserProjects({ id_or_username: userId }).pipe( + Effect.catch(() => + Console.log(` ${RED}Failed to list user projects${RESET}`).pipe( + Effect.map(() => [] as any[]), + ), + ), + ); + + if (projects.length === 0) { + yield* Console.log(` ${DIM}No projects found${RESET}`); + return; + } + + // Pass 1: delete all versions for each project (so dependents don't block). + yield* Console.log( + `\n ${BOLD}${CYAN}→ Pass 1: Versions (children of projects)${RESET}`, + ); + for (const project of projects) { + const projectId = project.id ?? "unknown"; + const projectSlug = + (project as any).slug ?? (project as any).title ?? projectId; + yield* Console.log( + `\n ${BOLD}Project: ${projectSlug}${RESET} ${DIM}(id: ${projectId})${RESET}`, + ); + yield* nukeProjectVersions( + dryRun, + nukeConfig, + projectId, + String(projectSlug), + ); + } + + // Pass 2: delete the projects themselves. + yield* Console.log( + `\n ${BOLD}${CYAN}→ Pass 2: Projects${RESET}`, + ); + for (const project of projects) { + const projectId = project.id ?? "unknown"; + const projectSlug = + (project as any).slug ?? (project as any).title ?? projectId; + const projectName = (project as any).title ?? String(projectSlug); + totalFound++; + + const excluded = isExcluded( + nukeConfig, + "Project", + projectId, + String(projectSlug), + ); + if (excluded) { + totalSkipped++; + yield* Console.log( + ` ${YELLOW}[SKIP]${RESET} Project: ${projectName} ${DIM}(id: ${projectId}, slug: ${projectSlug})${RESET} — ${excluded.reason ?? "excluded"}`, + ); + continue; + } + + if (dryRun) { + yield* Console.log( + ` ${RED}[DELETE]${RESET} Project: ${projectName} ${DIM}(id: ${projectId}, slug: ${projectSlug})${RESET}`, + ); + } else { + yield* Console.log( + ` ${RED}[DELETE]${RESET} Project: ${projectName} ${DIM}(id: ${projectId})${RESET}`, + ); + yield* deleteProject({ id_or_slug: projectId }).pipe( + Effect.andThen(() => { + totalDeleted++; + }), + Effect.catch((err) => { + totalFailed++; + return Console.log( + ` ${RED}Failed to delete project ${projectSlug}: ${(err as any)?._tag ?? "unknown"} ${(err as any)?.message ?? ""}${RESET}`, + ); + }), + ); + } + } + }); + +const nukeFollowedProjects = ( + dryRun: boolean, + nukeConfig: NukeConfig, + userId: string, +) => + Effect.gen(function* () { + yield* Console.log(`\n${BOLD}${CYAN}Followed Projects${RESET}`); + + const follows = yield* getFollowedProjects({ + id_or_username: userId, + }).pipe( + Effect.catch(() => + Console.log(` ${RED}Failed to list followed projects${RESET}`).pipe( + Effect.map(() => [] as any[]), + ), + ), + ); + + if (follows.length === 0) { + yield* Console.log(` ${DIM}No followed projects${RESET}`); + return; + } + + for (const project of follows) { + const projectId = project.id ?? "unknown"; + const projectSlug = + (project as any).slug ?? (project as any).title ?? projectId; + totalFound++; + + const excluded = isExcluded( + nukeConfig, + "FollowedProject", + projectId, + String(projectSlug), + ); + if (excluded) { + totalSkipped++; + yield* Console.log( + ` ${YELLOW}[SKIP]${RESET} FollowedProject: ${projectSlug} ${DIM}(id: ${projectId})${RESET} — ${excluded.reason ?? "excluded"}`, + ); + continue; + } + + if (dryRun) { + yield* Console.log( + ` ${RED}[UNFOLLOW]${RESET} FollowedProject: ${projectSlug} ${DIM}(id: ${projectId})${RESET}`, + ); + } else { + yield* Console.log( + ` ${RED}[UNFOLLOW]${RESET} FollowedProject: ${projectSlug} ${DIM}(id: ${projectId})${RESET}`, + ); + yield* unfollowProject({ id_or_slug: projectId }).pipe( + Effect.andThen(() => { + totalDeleted++; + }), + Effect.catch((err) => { + totalFailed++; + return Console.log( + ` ${RED}Failed to unfollow project ${projectSlug}: ${(err as any)?._tag ?? "unknown"}${RESET}`, + ); + }), + ); + } + } + }); + +const nukeNotifications = ( + dryRun: boolean, + nukeConfig: NukeConfig, + userId: string, +) => + Effect.gen(function* () { + yield* Console.log(`\n${BOLD}${CYAN}Notifications${RESET}`); + + const notifications = yield* getUserNotifications({ + id_or_username: userId, + }).pipe( + Effect.catch(() => + Console.log(` ${RED}Failed to list notifications${RESET}`).pipe( + Effect.map(() => [] as any[]), + ), + ), + ); + + if (notifications.length === 0) { + yield* Console.log(` ${DIM}No notifications${RESET}`); + return; + } + + for (const notification of notifications) { + const notificationId = notification.id ?? "unknown"; + const title = notification.title ?? "(untitled)"; + totalFound++; + + const excluded = isExcluded( + nukeConfig, + "Notification", + notificationId, + title, + ); + if (excluded) { + totalSkipped++; + yield* Console.log( + ` ${YELLOW}[SKIP]${RESET} Notification: ${title} ${DIM}(id: ${notificationId})${RESET} — ${excluded.reason ?? "excluded"}`, + ); + continue; + } + + if (dryRun) { + yield* Console.log( + ` ${RED}[DELETE]${RESET} Notification: ${title} ${DIM}(id: ${notificationId}, type: ${notification.type ?? "unknown"}, read: ${notification.read})${RESET}`, + ); + } else { + yield* Console.log( + ` ${RED}[DELETE]${RESET} Notification: ${title} ${DIM}(id: ${notificationId})${RESET}`, + ); + yield* deleteNotification({ id: notificationId }).pipe( + Effect.andThen(() => { + totalDeleted++; + }), + Effect.catch((err) => { + totalFailed++; + return Console.log( + ` ${RED}Failed to delete notification ${notificationId}: ${(err as any)?._tag ?? "unknown"}${RESET}`, + ); + }), + ); + } + } + }); + +// ============================================================================ +// Main command +// ============================================================================ + +const nuke = Command.make( + "nuke", + { + dryRun: Flag.boolean("dry-run").pipe( + Flag.withDescription("Only list resources without deleting them"), + Flag.withDefault(false), + ), + }, + (cfg) => + Effect.gen(function* () { + const nukeConfig = loadNukeConfig(); + const mode = cfg.dryRun + ? `${YELLOW}DRY RUN${RESET}` + : `${RED}LIVE${RESET}`; + yield* Console.log( + `\n${BOLD}Modrinth Nuke${RESET} ${DIM}(${mode}${DIM})${RESET}`, + ); + + if (!cfg.dryRun) { + yield* Console.log( + `${RED}${BOLD}WARNING: This will DELETE all resources owned by the authed user!${RESET}`, + ); + } + + if (nukeConfig.exclude && nukeConfig.exclude.length > 0) { + yield* Console.log( + `${DIM}Loaded ${nukeConfig.exclude.length} exclusion rule(s) from nuke-config.json${RESET}`, + ); + } + + // Resolve the authenticated user — every list endpoint we use is keyed on + // the user's id (or username). Without a token this 404s/401s and we abort. + const me = yield* getUserFromAuth({}).pipe( + Effect.catch((err) => + Console.log( + `${RED}Failed to resolve authed user: ${(err as any)?._tag ?? "unknown"} ${(err as any)?.message ?? ""}${RESET}\n${DIM}Set MODRINTH_API_KEY to a valid PAT and re-run.${RESET}`, + ).pipe(Effect.andThen(() => Effect.fail("no-auth" as const))), + ), + ); + + yield* Console.log( + `${DIM}Authenticated as: ${me.username} (id: ${me.id}, role: ${me.role})${RESET}`, + ); + + // Dependency order: versions before projects (children first), then + // independent resources (follows, notifications). + yield* nukeProjects(cfg.dryRun, nukeConfig, me.id); + yield* nukeFollowedProjects(cfg.dryRun, nukeConfig, me.id); + yield* nukeNotifications(cfg.dryRun, nukeConfig, me.id); + + // Summary + yield* Console.log(`\n${BOLD}Summary${RESET}`); + yield* Console.log(` Total found: ${totalFound}`); + yield* Console.log( + ` ${YELLOW}Skipped: ${totalSkipped}${RESET}`, + ); + if (!cfg.dryRun) { + yield* Console.log( + ` ${GREEN}Deleted: ${totalDeleted}${RESET}`, + ); + if (totalFailed > 0) { + yield* Console.log( + ` ${RED}Failed: ${totalFailed}${RESET}`, + ); + } + } + }).pipe( + Effect.catch((err) => + err === "no-auth" + ? Effect.void + : Console.log( + `${RED}Unexpected failure: ${String((err as any)?.message ?? err)}${RESET}`, + ), + ), + Effect.provide(CredentialsFromEnv), + Effect.provide(FetchHttpClient.layer), + ), +).pipe(Command.withDescription("List and delete all Modrinth resources")); + +// ============================================================================ +// Entry Point +// ============================================================================ + +BunRuntime.runMain( + Effect.provide(Command.run(nuke, { version: "1.0.0" }), BunServices.layer), +); diff --git a/packages/modrinth/specs/openapi.yaml b/packages/modrinth/specs/openapi.yaml new file mode 100644 index 000000000..d6a168d18 --- /dev/null +++ b/packages/modrinth/specs/openapi.yaml @@ -0,0 +1,3965 @@ +openapi: '3.0.0' + +info: + version: v2.7.0/366f528 + title: Labrinth + termsOfService: https://modrinth.com/legal/terms + contact: + name: Modrinth Support + url: https://support.modrinth.com + email: support@modrinth.com + description: | + This documentation doesn't provide a way to test our API. In order to facilitate testing, we recommend the following tools: + + - [cURL](https://curl.se/) (recommended, command-line) + - [ReqBIN](https://reqbin.com/) (recommended, online) + - [Postman](https://www.postman.com/downloads/) + - [Insomnia](https://insomnia.rest/) + - Your web browser, if you don't need to send headers or a request body + + Once you have a working client, you can test that it works by making a `GET` request to `https://staging-api.modrinth.com/`: + + ```json + { + "about": "Welcome traveler!", + "documentation": "https://docs.modrinth.com", + "name": "modrinth-labrinth", + "version": "2.7.0" + } + ``` + + If you got a response similar to the one above, you can use the Modrinth API! + When you want to go live using the production API, use `api.modrinth.com` instead of `staging-api.modrinth.com`. + + ## Authentication + This API has two options for authentication: personal access tokens and [OAuth2](https://en.wikipedia.org/wiki/OAuth). + All tokens are tied to a Modrinth user and use the `Authorization` header of the request. + + Example: + ``` + Authorization: mrp_RNtLRSPmGj2pd1v1ubi52nX7TJJM9sznrmwhAuj511oe4t1jAqAQ3D6Wc8Ic + ``` + + You do not need a token for most requests. Generally speaking, only the following types of requests require a token: + - those which create data (such as version creation) + - those which modify data (such as editing a project) + - those which access private data (such as draft projects, notifications, emails, and payout data) + + Each request requiring authentication has a certain scope. For example, to view the email of the user being requested, the token must have the `USER_READ_EMAIL` scope. + You can find the list of available scopes [on GitHub](https://github.com/modrinth/labrinth/blob/master/src/models/pats.rs#L15). Making a request with an invalid scope will return a 401 error. + + Please note that certain scopes and requests cannot be completed with a personal access token or using OAuth. + For example, deleting a user account can only be done through Modrinth's frontend. + + A detailed guide on OAuth has been published in [Modrinth's technical documentation](https://docs.modrinth.com/guide/oauth). + + ### Personal access tokens + Personal access tokens (PATs) can be generated in from [the user settings](https://modrinth.com/settings/account). + + ### GitHub tokens + For backwards compatibility purposes, some types of GitHub tokens also work for authenticating a user with Modrinth's API, granting all scopes. + **We urge any application still using GitHub tokens to start using personal access tokens for security and reliability purposes.** + GitHub tokens will cease to function to authenticate with Modrinth's API as soon as version 3 of the API is made generally available. + + ## Cross-Origin Resource Sharing + This API features Cross-Origin Resource Sharing (CORS) implemented in compliance with the [W3C spec](https://www.w3.org/TR/cors/). + This allows for cross-domain communication from the browser. + All responses have a wildcard same-origin which makes them completely public and accessible to everyone, including any code on any site. + + ## Identifiers + The majority of items you can interact with in the API have a unique eight-digit base62 ID. + Projects, versions, users, threads, teams, and reports all use this same way of identifying themselves. + Version files use the sha1 or sha512 file hashes as identifiers. + + Each project and user has a friendlier way of identifying them; slugs and usernames, respectively. + While unique IDs are constant, slugs and usernames can change at any moment. + If you want to store something in the long term, it is recommended to use the unique ID. + + ## Ratelimits + The API has a ratelimit defined per IP. Limits and remaining amounts are given in the response headers. + - `X-Ratelimit-Limit`: the maximum number of requests that can be made in a minute + - `X-Ratelimit-Remaining`: the number of requests remaining in the current ratelimit window + - `X-Ratelimit-Reset`: the time in seconds until the ratelimit window resets + + Ratelimits are the same no matter whether you use a token or not. + The ratelimit is currently 300 requests per minute. If you have a use case requiring a higher limit, please [contact us](mailto:support@modrinth.com). + + ## User Agents + To access the Modrinth API, you **must** use provide a uniquely-identifying `User-Agent` header. + Providing a user agent that only identifies your HTTP client library (such as "okhttp/4.9.3") increases the likelihood that we will block your traffic. + It is recommended, but not required, to include contact information in your user agent. + This allows us to contact you if we would like a change in your application's behavior without having to block your traffic. + - Bad: `User-Agent: okhttp/4.9.3` + - Good: `User-Agent: project_name` + - Better: `User-Agent: github_username/project_name/1.56.0` + - Best: `User-Agent: github_username/project_name/1.56.0 (launcher.com)` or `User-Agent: github_username/project_name/1.56.0 (contact@launcher.com)` + + ## Versioning + Modrinth follows a simple pattern for its API versioning. + In the event of a breaking API change, the API version in the URL path is bumped, and migration steps will be published below. + + When an API is no longer the current one, it will immediately be considered deprecated. + No more support will be provided for API versions older than the current one. + It will be kept for some time, but this amount of time is not certain. + + We will exercise various tactics to get people to update their implementation of our API. + One example is by adding something like `STOP USING THIS API` to various data returned by the API. + + Once an API version is completely deprecated, it will permanently return a 410 error. + Please ensure your application handles these 410 errors. + + ### Migrations + Inside the following spoiler, you will be able to find all changes between versions of the Modrinth API, accompanied by tips and a guide to migrate applications to newer versions. + + Here, you can also find changes for [Minotaur](https://github.com/modrinth/minotaur), Modrinth's official Gradle plugin. Major versions of Minotaur directly correspond to major versions of the Modrinth API. + +
API v1 to API v2 + + These bullet points cover most changes in the v2 API, but please note that fields containing `mod` in most contexts have been shifted to `project`. For example, in the search route, the field `mod_id` was renamed to `project_id`. + + - The search route has been moved from `/api/v1/mod` to `/v2/search` + - New project fields: `project_type` (may be `mod` or `modpack`), `moderation_message` (which has a `message` and `body`), `gallery` + - New search facet: `project_type` + - Alphabetical sort removed (it didn't work and is not possible due to limits in MeiliSearch) + - New search fields: `project_type`, `gallery` + - The gallery field is an array of URLs to images that are part of the project's gallery + - The gallery is a new feature which allows the user to upload images showcasing their mod to the CDN which will be displayed on their mod page + - Internal change: Any project file uploaded to Modrinth is now validated to make sure it's a valid Minecraft mod, Modpack, etc. + - For example, a Forge 1.17 mod with a JAR not containing a mods.toml will not be allowed to be uploaded to Modrinth + - In project creation, projects may not upload a mod with no versions to review, however they can be saved as a draft + - Similarly, for version creation, a version may not be uploaded without any files + - Donation URLs have been enabled + - New project status: `archived`. Projects with this status do not appear in search + - Tags (such as categories, loaders) now have icons (SVGs) and specific project types attached + - Dependencies have been wiped and replaced with a new system + - Notifications now have a `type` field, such as `project_update` + + Along with this, project subroutes (such as `/v2/project/{id}/version`) now allow the slug to be used as the ID. This is also the case with user routes. + +
Minotaur v1 to Minotaur v2 + + Minotaur 2.x introduced a few breaking changes to how your buildscript is formatted. + + First, instead of registering your own `publishModrinth` task, Minotaur now automatically creates a `modrinth` task. As such, you can replace the `task publishModrinth(type: TaskModrinthUpload) {` line with just `modrinth {`. + + To declare supported Minecraft versions and mod loaders, the `gameVersions` and `loaders` arrays must now be used. The syntax for these are pretty self-explanatory. + + Instead of using `releaseType`, you must now use `versionType`. This was actually changed in v1.2.0, but very few buildscripts have moved on from v1.1.0. + + Dependencies have been changed to a special DSL. Create a `dependencies` block within the `modrinth` block, and then use `scope.type("project/version")`. For example, `required.project("fabric-api")` adds a required project dependency on Fabric API. + + You may now use the slug anywhere that a project ID was previously required. + +
+ +# The above snippet about User Agents was adapted from https://crates.io/policies, copyright (c) 2014 The Rust Project Developers under MIT license + +servers: + - url: https://api.modrinth.com/v2 + description: Production server + - url: https://staging-api.modrinth.com/v2 + description: Staging server + +components: + parameters: + ProjectIdentifier: + name: id|slug + in: path + required: true + description: The ID or slug of the project + schema: + type: string + example: [AABBCCDD, my_project] + MultipleProjectIdentifier: + in: query + name: ids + description: The IDs and/or slugs of the projects + schema: + type: string + example: '["AABBCCDD", "EEFFGGHH"]' + required: true + UserIdentifier: + name: id|username + in: path + required: true + description: The ID or username of the user + schema: + type: string + example: [EEFFGGHH, my_user] + VersionIdentifier: + name: id + in: path + required: true + description: The ID of the version + schema: + type: string + example: [IIJJKKLL] + TeamIdentifier: + name: id + in: path + required: true + description: The ID of the team + schema: + type: string + example: [MMNNOOPP] + ReportIdentifier: + name: id + in: path + required: true + description: The ID of the report + schema: + type: string + example: [RRSSTTUU] + ThreadIdentifier: + name: id + in: path + required: true + description: The ID of the thread + schema: + type: string + example: [QQRRSSTT] + NotificationIdentifier: + name: id + in: path + required: true + description: The ID of the notification + schema: + type: string + example: [NNOOPPQQ] + AlgorithmIdentifier: + name: algorithm + in: query + required: true + description: The algorithm of the hash + schema: + type: string + enum: [sha1, sha512] + example: sha512 + default: sha1 + MultipleHashQueryIdentifier: + name: multiple + in: query + required: false + description: Whether to return multiple results when looking for this hash + schema: + type: boolean + default: false + FileHashIdentifier: + name: hash + in: path + required: true + description: The hash of the file, considering its byte content, and encoded in hexadecimal + schema: + type: string + example: 619e250c133106bacc3e3b560839bd4b324dfda8 + requestBodies: + Image: + content: + image/png: + schema: + type: string + format: binary + image/jpeg: + schema: + type: string + format: binary + image/bmp: + schema: + type: string + format: binary + image/gif: + schema: + type: string + format: binary + image/webp: + schema: + type: string + format: binary + image/svg: + schema: + type: string + format: binary + image/svgz: + schema: + type: string + format: binary + image/rgb: + schema: + type: string + format: binary + schemas: + # Version + BaseVersion: + type: object + properties: + name: + type: string + description: The name of this version + example: 'Version 1.0.0' + version_number: + type: string + description: 'The version number. Ideally will follow semantic versioning' + example: '1.0.0' + changelog: + type: string + description: 'The changelog for this version' + example: 'List of changes in this version: ...' + nullable: true + dependencies: + type: array + items: + $ref: '#/components/schemas/VersionDependency' + description: A list of specific versions of projects that this version depends on + game_versions: + type: array + items: + type: string + description: A list of versions of Minecraft that this version supports + example: ['1.16.5', '1.17.1'] + version_type: + type: string + description: The release channel for this version + enum: [release, beta, alpha] + example: release + loaders: + type: array + items: + type: string + description: The mod loaders that this version supports. In case of resource packs, use "minecraft" + example: ['fabric', 'forge', 'minecraft'] + featured: + type: boolean + description: Whether the version is featured or not + example: true + status: + type: string + enum: [listed, archived, draft, unlisted, scheduled, unknown] + example: listed + requested_status: + type: string + enum: [listed, archived, draft, unlisted] + nullable: true + VersionDependency: + type: object + properties: + version_id: + type: string + description: The ID of the version that this version depends on + example: IIJJKKLL + nullable: true + project_id: + type: string + description: The ID of the project that this version depends on + example: QQRRSSTT + nullable: true + file_name: + type: string + description: The file name of the dependency, mostly used for showing external dependencies on modpacks + example: sodium-fabric-mc1.19-0.4.2+build.16.jar + nullable: true + dependency_type: + type: string + enum: [required, optional, incompatible, embedded] + description: The type of dependency that this version has + example: required + required: + - dependency_type + + # https://github.com/modrinth/labrinth/blob/master/src/routes/versions.rs#L169-L190 + EditableVersion: + allOf: + - $ref: '#/components/schemas/BaseVersion' + - type: object + properties: + primary_file: + type: array + items: + type: string + example: [sha1, aaaabbbbccccddddeeeeffffgggghhhhiiiijjjj] + description: The hash format and the hash of the new primary file + file_types: + type: array + items: + $ref: '#/components/schemas/EditableFileType' + description: A list of file_types to edit + EditableFileType: + type: object + properties: + algorithm: + type: string + description: The hash algorithm of the hash specified in the hash field + example: sha1 + hash: + type: string + description: The hash of the file you're editing + example: aaaabbbbccccddddeeeeffffgggghhhhiiiijjjj + file_type: + allOf: + - $ref: '#/components/schemas/FileTypeEnum' + - nullable: true + description: The hash algorithm of the file you're editing + required: + - algorithm + - hash + - file_type + # https://github.com/modrinth/code/blob/main/apps/labrinth/src/models/v3/projects.rs#L981-990 + FileTypeEnum: + type: string + enum: + - required-resource-pack + - optional-resource-pack + - sources-jar + - dev-jar + - javadoc-jar + - unknown + - signature + example: required-resource-pack + # https://github.com/modrinth/labrinth/blob/master/src/routes/version_creation.rs#L27-L57 + CreatableVersion: + allOf: + - $ref: '#/components/schemas/BaseVersion' + - type: object + properties: + project_id: + type: string + description: The ID of the project this version is for + example: AABBCCDD + file_parts: + type: array + items: + type: string + description: An array of the multipart field names of each file that goes with this version + primary_file: + type: string + description: The multipart field name of the primary file + required: + - file_parts + - project_id + - name + - version_number + - game_versions + - version_type + - loaders + - featured + - dependencies + CreateVersionBody: + type: object + properties: + data: + $ref: '#/components/schemas/CreatableVersion' + required: [data] + Version: + allOf: + - $ref: '#/components/schemas/BaseVersion' + - type: object + properties: + id: + type: string + description: The ID of the version, encoded as a base62 string + example: IIJJKKLL + project_id: + type: string + description: The ID of the project this version is for + example: AABBCCDD + author_id: + type: string + description: The ID of the author who published this version + example: EEFFGGHH + date_published: + type: string + format: ISO-8601 + downloads: + type: integer + description: The number of times this version has been downloaded + changelog_url: + type: string + description: A link to the changelog for this version. Always null, only kept for legacy compatibility. + deprecated: true + example: null + nullable: true + files: + type: array + items: + $ref: '#/components/schemas/VersionFile' + description: A list of files available for download for this version + required: + - id + - project_id + - author_id + - date_published + - downloads + - files + - name + - version_number + - game_versions + - version_type + - loaders + - featured + VersionFile: + type: object + properties: + hashes: + $ref: '#/components/schemas/VersionFileHashes' + url: + type: string + example: 'https://cdn.modrinth.com/data/AABBCCDD/versions/1.0.0/my_file.jar' + description: A direct link to the file + filename: + type: string + example: 'my_file.jar' + description: The name of the file + primary: + type: boolean + example: false + description: Whether this file is the primary one for its version. Only a maximum of one file per version will have this set to true. If there are not any primary files, it can be inferred that the first file is the primary one. + size: + type: integer + example: 1097270 + description: The size of the file in bytes + file_type: + allOf: + - $ref: '#/components/schemas/FileTypeEnum' + - nullable: true + description: The type of the additional file, used mainly for adding resource packs to datapacks + required: + - hashes + - url + - filename + - primary + - size + VersionFileHashes: + type: object + properties: + sha512: + type: string + example: 93ecf5fe02914fb53d94aa3d28c1fb562e23985f8e4d48b9038422798618761fe208a31ca9b723667a4e05de0d91a3f86bcd8d018f6a686c39550e21b198d96f + sha1: + type: string + example: c84dd4b3580c02b79958a0590afd5783d80ef504 + description: A map of hashes of the file. The key is the hashing algorithm and the value is the string version of the hash. + GetLatestVersionFromHashBody: + type: object + properties: + loaders: + type: array + items: + type: string + example: [fabric] + game_versions: + type: array + items: + type: string + example: ['1.18', 1.18.1] + required: + - loaders + - game_versions + HashVersionMap: + description: 'A map from hashes to versions' + type: object + additionalProperties: + $ref: '#/components/schemas/Version' + HashList: + description: 'A list of hashes and the algorithm used to create them' + type: object + properties: + hashes: + type: array + items: + type: string + example: + [ + ea0f38408102e4d2efd53c2cc11b88b711996b48d8922f76ea6abf731219c5bd1efe39ddf9cce77c54d49a62ff10fb685c00d2e4c524ab99d20f6296677ab2c4, + 925a5c4899affa4098d997dfa4a4cb52c636d539e94bc489d1fa034218cb96819a70eb8b01647a39316a59fcfe223c1a8c05ed2e2ae5f4c1e75fa48f6af1c960, + ] + algorithm: + type: string + enum: [sha1, sha512] + example: sha512 + required: + - hashes + - algorithm + GetLatestVersionsFromHashesBody: + allOf: + - $ref: '#/components/schemas/HashList' + - type: object + properties: + loaders: + type: array + items: + type: string + example: [fabric] + game_versions: + type: array + items: + type: string + example: ['1.18', 1.18.1] + required: + - loaders + - game_versions + # Project + # Fields that can be used in everything. Search, direct project lookup, project editing, you name it. + BaseProject: + type: object + properties: + slug: + type: string + description: "The slug of a project, used for vanity URLs. Regex: ```^[\\w!@$()`.+,\"\\-']{3,64}$```" + example: my_project + title: + type: string + description: The title or name of the project + example: My Project + description: + type: string + description: A short description of the project + example: A short description + categories: + type: array + items: + type: string + example: [technology, adventure, fabric] + description: A list of the categories that the project has + client_side: + type: string + enum: [required, optional, unsupported, unknown] + description: The client side support of the project + example: required + server_side: + type: string + enum: [required, optional, unsupported, unknown] + description: The server side support of the project + example: optional + # Fields added to search results and direct project lookups that cannot be edited. + ServerRenderedProject: + allOf: + - $ref: '#/components/schemas/BaseProject' + - type: object + properties: + project_type: + type: string + enum: [mod, modpack, resourcepack, shader] + description: The project type of the project + example: mod + downloads: + type: integer + description: The total number of downloads of the project + icon_url: + type: string + example: https://cdn.modrinth.com/data/AABBCCDD/b46513nd83hb4792a9a0e1fn28fgi6090c1842639.png + description: The URL of the project's icon + nullable: true + color: + type: integer + example: 8703084 + description: The RGB color of the project, automatically generated from the project icon + nullable: true + thread_id: + type: string + example: TTUUVVWW + description: The ID of the moderation thread associated with this project + monetization_status: + type: string + enum: [monetized, demonetized, force-demonetized] + required: + - project_type + - downloads + # The actual result in search. + ProjectResult: + allOf: + - $ref: '#/components/schemas/ServerRenderedProject' + - type: object + properties: + project_id: + type: string + description: The ID of the project + example: AABBCCDD + author: + type: string + description: The username of the project's author + example: my_user + display_categories: + type: array + items: + type: string + description: A list of the categories that the project has which are not secondary + example: ['technology', 'fabric'] + versions: + type: array + items: + type: string + description: A list of the minecraft versions supported by the project + example: ['1.8', '1.8.9'] + follows: + type: integer + description: The total number of users following the project + date_created: + type: string + format: ISO-8601 + description: The date the project was added to search + date_modified: + type: string + format: ISO-8601 + description: The date the project was last modified + latest_version: + type: string + description: The latest version of minecraft that this project supports + example: 1.8.9 + license: + type: string + description: The SPDX license ID of a project + example: MIT + gallery: + type: array + description: All gallery images attached to the project + example: + [ + 'https://cdn.modrinth.com/data/AABBCCDD/images/009b7d8d6e8bf04968a29421117c59b3efe2351a.png', + 'https://cdn.modrinth.com/data/AABBCCDD/images/c21776867afb6046fdc3c21dbcf5cc50ae27a236.png', + ] + items: + type: string + featured_gallery: + type: string + description: The featured gallery image of the project + nullable: true + required: + - slug + - title + - description + - client_side + - server_side + - project_id + - author + - versions + - follows + - date_created + - date_modified + - license + # Fields that appear everywhere EXCEPT search. + NonSearchProject: + allOf: + - $ref: '#/components/schemas/BaseProject' + - type: object + properties: + body: + type: string + description: A long form description of the project + example: A long body describing my project in detail + status: + type: string + enum: + [ + approved, + archived, + rejected, + draft, + unlisted, + processing, + withheld, + scheduled, + private, + unknown, + ] + description: The status of the project + example: approved + requested_status: + type: string + enum: [approved, archived, unlisted, private, draft] + description: The requested status when submitting for review or scheduling the project for release + nullable: true + additional_categories: + type: array + items: + type: string + description: A list of categories which are searchable but non-primary + example: [technology, adventure, fabric] + issues_url: + type: string + description: An optional link to where to submit bugs or issues with the project + example: https://github.com/my_user/my_project/issues + nullable: true + source_url: + type: string + description: An optional link to the source code of the project + example: https://github.com/my_user/my_project + nullable: true + wiki_url: + type: string + description: An optional link to the project's wiki page or other relevant information + example: https://github.com/my_user/my_project/wiki + nullable: true + discord_url: + type: string + description: An optional invite link to the project's discord + example: https://discord.gg/AaBbCcDd + nullable: true + donation_urls: + type: array + items: + $ref: '#/components/schemas/ProjectDonationURL' + description: A list of donation links for the project + ProjectDonationURL: + type: object + properties: + id: + type: string + description: The ID of the donation platform + example: patreon + platform: + type: string + description: The donation platform this link is to + example: Patreon + url: + type: string + description: The URL of the donation platform and user + example: https://www.patreon.com/my_user + # Fields available only when editing or creating a project + ModifiableProject: + allOf: + - $ref: '#/components/schemas/NonSearchProject' + - type: object + properties: + license_id: + type: string + description: The SPDX license ID of a project + example: LGPL-3.0-or-later + license_url: + type: string + description: The URL to this license + nullable: true + # Fields that can be edited through a PATCH request. https://github.com/modrinth/labrinth/blob/master/src/routes/projects.rs#L195-L269 + EditableProject: + allOf: + - $ref: '#/components/schemas/ModifiableProject' + - type: object + properties: + moderation_message: + type: string + description: The title of the moderators' message for the project + nullable: true + moderation_message_body: + type: string + description: The body of the moderators' message for the project + nullable: true + # Fields only available for project creation. https://github.com/modrinth/labrinth/blob/master/src/routes/project_creation.rs#L129-L197 + CreatableProject: + allOf: + - $ref: '#/components/schemas/ModifiableProject' + - type: object + properties: + project_type: + type: string + enum: [mod, modpack] + example: modpack + initial_versions: + type: array + items: + $ref: '#/components/schemas/EditableVersion' + description: A list of initial versions to upload with the created project. Deprecated - please upload version files after initial upload. + deprecated: true + is_draft: + type: boolean + description: Whether the project should be saved as a draft instead of being sent to moderation for review. Deprecated - please always mark this as true. + example: true + deprecated: true + gallery_items: + type: array + description: Gallery images to be uploaded with the created project. Deprecated - please upload gallery images after initial upload. + deprecated: true + items: + $ref: '#/components/schemas/CreatableProjectGalleryItem' + required: + - project_type + - slug + - title + - description + - body + - categories + - client_side + - server_side + - license_id + CreatableProjectGalleryItem: + type: object + nullable: true + properties: + item: + type: string + description: The name of the multipart item where the gallery media is located + featured: + type: boolean + description: Whether the image is featured in the gallery + example: true + title: + type: string + description: The title of the gallery image + example: My awesome screenshot! + nullable: true + description: + type: string + description: The description of the gallery image + example: This awesome screenshot shows all of the blocks in my mod! + nullable: true + ordering: + type: integer + description: The order of the gallery image. Gallery images are sorted by this field and then alphabetically by title. + example: 0 + Project: + allOf: + - $ref: '#/components/schemas/NonSearchProject' + - $ref: '#/components/schemas/ServerRenderedProject' + - type: object + properties: + id: + type: string + example: AABBCCDD + description: The ID of the project, encoded as a base62 string + team: + type: string + example: MMNNOOPP + description: The ID of the team that has ownership of this project + body_url: + type: string + deprecated: true + default: null + description: The link to the long description of the project. Always null, only kept for legacy compatibility. + example: null + nullable: true + moderator_message: + $ref: '#/components/schemas/ModeratorMessage' + published: + type: string + format: ISO-8601 + description: The date the project was published + updated: + type: string + format: ISO-8601 + description: The date the project was last updated + approved: + type: string + format: ISO-8601 + description: The date the project's status was set to an approved status + nullable: true + queued: + type: string + format: ISO-8601 + description: The date the project's status was submitted to moderators for review + nullable: true + followers: + type: integer + description: The total number of users following the project + license: + $ref: '#/components/schemas/ProjectLicense' + versions: + type: array + items: + type: string + example: [IIJJKKLL, QQRRSSTT] + description: A list of the version IDs of the project (will never be empty unless `draft` status) + game_versions: + type: array + items: + type: string + example: ['1.19', '1.19.1', '1.19.2', '1.19.3'] + description: A list of all of the game versions supported by the project + loaders: + type: array + items: + type: string + example: ['forge', 'fabric', 'quilt'] + description: A list of all of the loaders supported by the project + gallery: + type: array + items: + $ref: '#/components/schemas/GalleryImage' + description: A list of images that have been uploaded to the project's gallery + required: + - id + - team + - published + - updated + - followers + - title + - description + - categories + - client_side + - server_side + - slug + - body + - status + ModeratorMessage: + deprecated: true + type: object + properties: + message: + type: string + description: The message that a moderator has left for the project + body: + type: string + description: The longer body of the message that a moderator has left for the project + nullable: true + nullable: true + example: null + description: A message that a moderator sent regarding the project + ProjectLicense: + type: object + properties: + id: + type: string + description: The SPDX license ID of a project + example: LGPL-3.0-or-later + name: + type: string + description: The long name of a license + example: GNU Lesser General Public License v3 or later + url: + type: string + description: The URL to this license + nullable: true + description: The license of the project + GalleryImage: + type: object + nullable: true + properties: + url: + type: string + description: The URL of the gallery image + example: https://cdn.modrinth.com/data/AABBCCDD/images/009b7d8d6e8bf04968a29421117c59b3efe2351a.png + featured: + type: boolean + description: Whether the image is featured in the gallery + example: true + title: + type: string + description: The title of the gallery image + example: My awesome screenshot! + nullable: true + description: + type: string + description: The description of the gallery image + example: This awesome screenshot shows all of the blocks in my mod! + nullable: true + created: + type: string + format: ISO-8601 + description: The date and time the gallery image was created + ordering: + type: integer + description: The order of the gallery image. Gallery images are sorted by this field and then alphabetically by title. + example: 0 + required: + - url + - featured + - created + ProjectDependencyList: + type: object + properties: + projects: + type: array + items: + $ref: '#/components/schemas/Project' + description: Projects that the project depends upon + versions: + type: array + items: + $ref: '#/components/schemas/Version' + description: Versions that the project depends upon + PatchProjectsBody: + type: object + properties: + categories: + description: Set all of the categories to the categories specified here + type: array + items: + type: string + add_categories: + description: Add all of the categories specified here + type: array + items: + type: string + remove_categories: + description: Remove all of the categories specified here + type: array + items: + type: string + additional_categories: + description: Set all of the additional categories to the categories specified here + type: array + items: + type: string + add_additional_categories: + description: Add all of the additional categories specified here + type: array + items: + type: string + remove_additional_categories: + description: Remove all of the additional categories specified here + type: array + items: + type: string + donation_urls: + description: Set all of the donation links to the donation links specified here + type: array + items: + $ref: '#/components/schemas/ProjectDonationURL' + add_donation_urls: + description: Add all of the donation links specified here + type: array + items: + $ref: '#/components/schemas/ProjectDonationURL' + remove_donation_urls: + description: Remove all of the donation links specified here + type: array + items: + $ref: '#/components/schemas/ProjectDonationURL' + issues_url: + type: string + description: An optional link to where to submit bugs or issues with the projects + example: https://github.com/my_user/my_project/issues + nullable: true + source_url: + type: string + description: An optional link to the source code of the projects + example: https://github.com/my_user/my_project + nullable: true + wiki_url: + type: string + description: An optional link to the projects' wiki page or other relevant information + example: https://github.com/my_user/my_project/wiki + nullable: true + discord_url: + type: string + description: An optional invite link to the projects' discord + example: https://discord.gg/AaBbCcDd + nullable: true + CreateProjectBody: + type: object + properties: + data: + $ref: '#/components/schemas/CreatableProject' + icon: + type: string + format: binary + enum: ['*.png', '*.jpg', '*.jpeg', '*.bmp', '*.gif', '*.webp', '*.svg', '*.svgz', '*.rgb'] + description: Project icon file + required: [data] + ProjectIdentifier: + type: object + properties: + id: + type: string + example: AABBCCDD + Schedule: + type: object + properties: + time: + type: string + format: ISO-8601 + example: '2023-02-05T19:39:55.551839Z' + requested_status: + type: string + enum: [approved, archived, unlisted, private, draft] + description: The requested status when scheduling the project for release + required: + - time + - requested_status + # Search + SearchResults: + type: object + properties: + hits: + type: array + items: + $ref: '#/components/schemas/ProjectResult' + description: The list of results + offset: + type: integer + description: The number of results that were skipped by the query + example: 0 + limit: + type: integer + description: The number of results that were returned by the query + example: 10 + total_hits: + type: integer + description: The total number of results that match the query + example: 10 + required: + - hits + - offset + - limit + - total_hits + # User + UserIdentifier: + properties: + user_id: + type: string + example: EEFFGGHH + required: + - user_id + EditableUser: + type: object + properties: + username: + type: string + description: The user's username + example: my_user + name: + type: string + example: My User + description: The user's display name + nullable: true + email: + type: string + format: email + description: The user's email (only displayed if requesting your own account). Requires `USER_READ_EMAIL` PAT scope. + nullable: true + bio: + type: string + example: My short biography + description: A description of the user + payout_data: + $ref: '#/components/schemas/UserPayoutData' + required: + - username + UserPayoutData: + type: object + description: Various data relating to the user's payouts status (you can only see your own) + nullable: true + properties: + balance: + type: integer + description: The payout balance available for the user to withdraw (note, you cannot modify this in a PATCH request) + example: 10.11223344556677889900 + payout_wallet: + type: string + enum: [paypal, venmo] + description: The wallet that the user has selected + example: paypal + payout_wallet_type: + type: string + enum: [email, phone, user_handle] + description: The type of the user's wallet + example: email + payout_address: + type: string + description: The user's payout address + example: support@modrinth.com + User: + allOf: + - $ref: '#/components/schemas/EditableUser' + - type: object + properties: + id: + type: string + example: EEFFGGHH + description: The user's ID + avatar_url: + type: string + example: https://avatars.githubusercontent.com/u/11223344?v=1 + description: The user's avatar url + created: + type: string + format: ISO-8601 + description: The time at which the user was created + role: + type: string + enum: [admin, moderator, developer] + description: The user's role + example: developer + badges: + type: integer + format: bitfield + example: 63 + description: | + Any badges applicable to this user. These are currently unused and undisplayed, and as such are subject to change + + In order from first to seventh bit, the current bits are: + - (unused) + - EARLY_MODPACK_ADOPTER + - EARLY_RESPACK_ADOPTER + - EARLY_PLUGIN_ADOPTER + - ALPHA_TESTER + - CONTRIBUTOR + - TRANSLATOR + auth_providers: + type: array + items: + type: string + example: [github, gitlab, steam, microsoft, google, discord] + description: A list of authentication providers you have signed up for (only displayed if requesting your own account) + nullable: true + email_verified: + type: boolean + description: Whether your email is verified (only displayed if requesting your own account) + nullable: true + has_password: + type: boolean + description: Whether you have a password associated with your account (only displayed if requesting your own account) + nullable: true + has_totp: + type: boolean + description: Whether you have TOTP two-factor authentication connected to your account (only displayed if requesting your own account) + nullable: true + github_id: + deprecated: true + type: integer + description: Deprecated - this is no longer public for security reasons and is always null + example: null + nullable: true + required: + - id + - avatar_url + - created + - role + UserPayoutHistory: + type: object + properties: + all_time: + type: string + description: The all-time balance accrued by this user in USD + example: 10.11223344556677889900 + last_month: + type: string + description: The amount in USD made by the user in the previous 30 days + example: 2.22446688002244668800 + payouts: + type: array + description: A history of all of the user's past transactions + items: + $ref: '#/components/schemas/UserPayoutHistoryEntry' + UserPayoutHistoryEntry: + type: object + properties: + created: + type: string + format: ISO-8601 + description: The date of this transaction + amount: + type: integer + description: The amount of this transaction in USD + example: 10.00 + status: + type: string + description: The status of this transaction + example: success + # Notifications + Notification: + type: object + properties: + id: + type: string + description: The id of the notification + example: UUVVWWXX + user_id: + type: string + description: The id of the user who received the notification + example: EEFFGGHH + type: + type: string + enum: [project_update, team_invite, status_change, moderator_message] + description: The type of notification + example: project_update + nullable: true + title: + type: string + description: The title of the notification + example: '**My Project** has been updated!' + text: + type: string + description: The body text of the notification + example: 'The project, My Project, has released a new version: 1.0.0' + link: + type: string + description: A link to the related project or version + example: mod/AABBCCDD/version/IIJJKKLL + read: + type: boolean + example: false + description: Whether the notification has been read or not + created: + type: string + format: ISO-8601 + description: The time at which the notification was created + actions: + type: array + items: + $ref: '#/components/schemas/NotificationAction' + description: A list of actions that can be performed + required: + - id + - user_id + - title + - text + - link + - read + - created + - actions + NotificationAction: + type: object + description: An action that can be performed on a notification + properties: + title: + type: string + description: The friendly name for this action + example: Accept + action_route: + type: array + items: + type: string + description: The HTTP code and path to request in order to perform this action. + example: [POST, 'team/{id}/join'] + # Reports + CreatableReport: + type: object + properties: + report_type: + type: string + description: The type of the report being sent + example: copyright + item_id: + type: string + description: The ID of the item (project, version, or user) being reported + example: EEFFGGHH + item_type: + type: string + enum: [project, user, version] + description: The type of the item being reported + example: project + body: + type: string + description: The extended explanation of the report + example: This is a reupload of my mod, AABBCCDD! + required: + - report_type + - item_id + - item_type + - body + Report: + type: object + allOf: + - $ref: '#/components/schemas/CreatableReport' + - type: object + properties: + id: + type: string + description: The ID of the report + example: VVWWXXYY + reporter: + type: string + description: The ID of the user who reported the item + example: UUVVWWXX + created: + type: string + format: ISO-8601 + description: The time at which the report was created + closed: + type: boolean + description: Whether the report is resolved + thread_id: + type: string + example: TTUUVVWW + description: The ID of the moderation thread associated with this report + required: + - reporter + - created + - closed + - thread_id + # Threads + Thread: + type: object + properties: + id: + type: string + example: WWXXYYZZ + description: The ID of the thread + type: + type: string + enum: [project, report, direct_message] + project_id: + type: string + nullable: true + description: The ID of the associated project if a project thread + report_id: + type: string + nullable: true + description: The ID of the associated report if a report thread + messages: + type: array + items: + $ref: '#/components/schemas/ThreadMessage' + members: + type: array + items: + $ref: '#/components/schemas/User' + required: + - id + - type + - messages + - members + ThreadMessage: + type: object + properties: + id: + type: string + description: The ID of the message itself + example: MMNNOOPP + author_id: + type: string + description: The ID of the author + example: QQRRSSTT + nullable: true + body: + $ref: '#/components/schemas/ThreadMessageBody' + created: + type: string + format: ISO-8601 + description: The time at which the message was created + required: + - id + - body + - created + ThreadMessageBody: + type: object + description: The contents of the message. **Fields will vary depending on message type.** + properties: + type: + type: string + enum: [status_change, text, thread_closure, deleted] + description: The type of message + example: status_change + body: + type: string + description: The actual message text. **Only present for `text` message type** + example: This is the text of the message. + private: + type: boolean + description: Whether the message is only visible to moderators. **Only present for `text` message type** + example: false + replying_to: + type: string + description: The ID of the message being replied to by this message. **Only present for `text` message type** + nullable: true + example: SSTTUUVV + old_status: + type: string + enum: + [ + approved, + archived, + rejected, + draft, + unlisted, + processing, + withheld, + scheduled, + private, + unknown, + ] + description: The old status of the project. **Only present for `status_change` message type** + example: processing + new_status: + type: string + enum: + [ + approved, + archived, + rejected, + draft, + unlisted, + processing, + withheld, + scheduled, + private, + unknown, + ] + description: The new status of the project. **Only present for `status_change` message type** + example: approved + required: + - type + # Team + TeamMember: + type: object + properties: + team_id: + type: string + example: MMNNOOPP + description: The ID of the team this team member is a member of + user: + $ref: '#/components/schemas/User' + role: + type: string + example: Member + description: The user's role on the team + permissions: + type: integer + format: bitfield + example: 127 + description: | + The user's permissions in bitfield format (requires authorization to view) + + In order from first to tenth bit, the bits are: + - UPLOAD_VERSION + - DELETE_VERSION + - EDIT_DETAILS + - EDIT_BODY + - MANAGE_INVITES + - REMOVE_MEMBER + - EDIT_MEMBER + - DELETE_PROJECT + - VIEW_ANALYTICS + - VIEW_PAYOUTS + accepted: + type: boolean + example: true + description: Whether or not the user has accepted to be on the team (requires authorization to view) + payouts_split: + type: integer + example: 100 + description: The split of payouts going to this user. The proportion of payouts they get is their split divided by the sum of the splits of all members. + ordering: + type: integer + example: 0 + description: The order of the team member. + required: + - team_id + - user + - role + - accepted + # Tags + CategoryTag: + type: object + properties: + icon: + type: string + description: The SVG icon of a category + example: + name: + type: string + description: The name of the category + example: 'adventure' + project_type: + type: string + description: The project type this category is applicable to + example: mod + header: + type: string + description: The header under which the category should go + example: 'resolutions' + required: + - icon + - name + - project_type + - header + LoaderTag: + type: object + properties: + icon: + type: string + description: The SVG icon of a loader + example: + name: + type: string + description: The name of the loader + example: fabric + supported_project_types: + type: array + items: + type: string + description: The project type + description: The project types that this loader is applicable to + example: [mod, modpack] + required: + - icon + - name + - supported_project_types + GameVersionTag: + type: object + properties: + version: + type: string + description: The name/number of the game version + example: 1.18.1 + version_type: + type: string + enum: [release, snapshot, alpha, beta] + description: The type of the game version + example: release + date: + type: string + format: ISO-8601 + description: The date of the game version release + major: + type: boolean + description: Whether or not this is a major version, used for Featured Versions + example: true + required: + - version + - version_type + - date + - major + DonationPlatformTag: + type: object + properties: + short: + type: string + description: The short identifier of the donation platform + example: bmac + name: + type: string + description: The full name of the donation platform + example: Buy Me a Coffee + required: + - short + - name + ModifyTeamMemberBody: + properties: + role: + type: string + example: Contributor + permissions: + type: integer + format: bitfield + example: 127 + description: | + The user's permissions in bitfield format + + In order from first to tenth bit, the bits are: + - UPLOAD_VERSION + - DELETE_VERSION + - EDIT_DETAILS + - EDIT_BODY + - MANAGE_INVITES + - REMOVE_MEMBER + - EDIT_MEMBER + - DELETE_PROJECT + - VIEW_ANALYTICS + - VIEW_PAYOUTS + payouts_split: + type: integer + example: 100 + description: The split of payouts going to this user. The proportion of payouts they get is their split divided by the sum of the splits of all members. + ordering: + type: integer + example: 0 + description: The order of the team member. + LicenseTag: + type: object + description: A short overview of a license + properties: + short: + type: string + description: The short identifier of the license + example: lgpl-3 + name: + type: string + description: The full name of the license + example: GNU Lesser General Public License v3 + required: + - short + - name + License: + type: object + description: A full license + properties: + title: + type: string + example: GNU Lesser General Public License v3.0 or later + body: + type: string + example: Insert the entire text of the LGPL-3.0 here... + # Errors + InvalidInputError: + type: object + properties: + error: + type: string + description: The name of the error + example: 'invalid_input' + description: + type: string + description: The contents of the error + example: 'Error while parsing multipart payload' + required: + - error + - description + AuthError: + type: object + properties: + error: + type: string + description: The name of the error + example: 'unauthorized' + description: + type: string + description: The contents of the error + example: 'Authentication Error: Invalid Authentication Credentials' + required: + - error + - description + # Other + Statistics: + type: object + properties: + projects: + type: integer + description: Number of projects on Modrinth + versions: + type: integer + description: Number of versions on Modrinth + files: + type: integer + description: Number of version files on Modrinth + authors: + type: integer + description: Number of authors (users with projects) on Modrinth + ForgeUpdates: + type: object + description: Mod version information that can be consumed by Forge's update checker + properties: + homepage: + type: string + description: A link to the mod page + example: https://modrinth.com + promos: + $ref: '#/components/schemas/ForgeUpdateCheckerPromos' + ForgeUpdateCheckerPromos: + type: object + description: A list of the recommended and latest versions for each Minecraft release + properties: + '{version}-recommended': + type: string + description: The mod version that is recommended for `{version}`. Excludes versions with the `alpha` and `beta` version types. + '{version}-latest': + type: string + description: The latest mod version for `{version}`. Shows versions with the `alpha` and `beta` version types. + securitySchemes: + TokenAuth: + type: apiKey + in: header + name: Authorization + +tags: + - name: projects + x-displayName: Projects + description: Projects are what Modrinth is centered around, be it mods, modpacks, resource packs, etc. + - name: versions + x-displayName: Versions + description: Versions contain download links to files with additional metadata. + - name: version-files + x-displayName: Version Files + description: Versions can contain multiple files, and these routes help manage those files. + - name: users + x-displayName: Users + description: Users can create projects, join teams, access notifications, manage settings, and follow projects. Admins and moderators have more advanced permissions such as reviewing new projects. + - name: notifications + x-displayName: Notifications + description: Notifications are sent to users for various reasons, including for project updates, team invites, and moderation purposes. + - name: threads + x-displayName: Threads + description: Threads are a way of communicating between users and moderators, for the purposes of project reviews and reports. + - name: teams + x-displayName: Teams + description: Through teams, user permissions limit how team members can modify projects. + - name: tags + x-displayName: Tags + description: Tags are common and reusable lists of metadata types such as categories or versions. Some can be applied to projects and/or versions. + - name: misc + x-displayName: Miscellaneous + - name: project_model + x-displayName: Project Model + description: | + + - name: project_result_model + x-displayName: Search Result Model + description: | + + - name: version_model + x-displayName: Version Model + description: | + + - name: user_model + x-displayName: User Model + description: | + + - name: team_member_model + x-displayName: Team Member Model + description: | + + +x-tagGroups: + - name: Routes + tags: + - projects + - versions + - version-files + - users + - notifications + - threads + - teams + - tags + - misc + - name: Models + tags: + - project_model + - project_result_model + - version_model + - user_model + - team_member_model + +paths: + # Project + /search: + get: + summary: Search projects + operationId: searchProjects + parameters: + - in: query + name: query + schema: + type: string + example: gravestones + description: The query to search for + - in: query + name: facets + schema: + type: string + example: '[["categories:forge"],["versions:1.17.1"],["project_type:mod"],["license:mit"]]' + description: | + Facets are an essential concept for understanding how to filter out results. + + These are the most commonly used facet types: + - `project_type` + - `categories` (loaders are lumped in with categories in search) + - `versions` + - `client_side` + - `server_side` + - `open_source` + + Several others are also available for use, though these should not be used outside very specific use cases. + - `title` + - `author` + - `follows` + - `project_id` + - `license` + - `downloads` + - `color` + - `created_timestamp` (uses Unix timestamp) + - `modified_timestamp` (uses Unix timestamp) + - `date_created` (uses ISO-8601 timestamp) + - `date_modified` (uses ISO-8601 timestamp) + + In order to then use these facets, you need a value to filter by, as well as an operation to perform on this value. + The most common operation is `:` (same as `=`), though you can also use `!=`, `>=`, `>`, `<=`, and `<`. + Join together the type, operation, and value, and you've got your string. + ``` + {type} {operation} {value} + ``` + + Examples: + ``` + categories = adventure + versions != 1.20.1 + downloads <= 100 + ``` + + You then join these strings together in arrays to signal `AND` and `OR` operators. + + ##### OR + All elements in a single array are considered to be joined by OR statements. + For example, the search `[["versions:1.16.5", "versions:1.17.1"]]` translates to `Projects that support 1.16.5 OR 1.17.1`. + + ##### AND + Separate arrays are considered to be joined by AND statements. + For example, the search `[["versions:1.16.5"], ["project_type:modpack"]]` translates to `Projects that support 1.16.5 AND are modpacks`. + - in: query + name: index + schema: + type: string + enum: + - relevance + - downloads + - follows + - newest + - updated + default: relevance + example: downloads + description: The sorting method used for sorting search results + - in: query + name: offset + schema: + type: integer + default: 0 + example: 20 + description: The offset into the search. Skips this number of results + - in: query + name: limit + schema: + type: integer + default: 10 + example: 20 + minimum: 0 + maximum: 100 + description: The number of results returned by the search + tags: + - projects + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/SearchResults' + '400': + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + /project/{id|slug}: + parameters: + - $ref: '#/components/parameters/ProjectIdentifier' + get: + summary: Get a project + operationId: getProject + tags: + - projects + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Project' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + patch: + summary: Modify a project + operationId: modifyProject + tags: + - projects + security: + - TokenAuth: ['PROJECT_WRITE'] + requestBody: + description: 'Modified project fields' + content: + application/json: + schema: + $ref: '#/components/schemas/EditableProject' + responses: + '204': + description: Expected response to a valid request + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + delete: + summary: Delete a project + operationId: deleteProject + tags: + - projects + security: + - TokenAuth: ['PROJECT_DELETE'] + responses: + '204': + description: Expected response to a valid request + '400': + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + /projects: + parameters: + - $ref: '#/components/parameters/MultipleProjectIdentifier' + get: + summary: Get multiple projects + operationId: getProjects + tags: + - projects + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Project' + patch: + summary: Bulk-edit multiple projects + operationId: patchProjects + tags: + - projects + security: + - TokenAuth: ['PROJECT_WRITE'] + requestBody: + description: Fields to edit on all projects specified + content: + application/json: + schema: + $ref: '#/components/schemas/PatchProjectsBody' + responses: + '204': + description: Expected response to a valid request + '400': + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + /projects_random: + get: + summary: Get a list of random projects + operationId: randomProjects + parameters: + - in: query + name: count + required: true + schema: + type: integer + example: 70 + minimum: 0 + maximum: 100 + description: The number of random projects to return + tags: + - projects + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Project' + '400': + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + /project: + post: + summary: Create a project + operationId: createProject + tags: + - projects + security: + - TokenAuth: ['PROJECT_CREATE'] + requestBody: + description: 'New project' + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/CreateProjectBody' + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Project' + '400': + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + /project/{id|slug}/icon: + parameters: + - $ref: '#/components/parameters/ProjectIdentifier' + patch: + summary: Change project's icon + description: The new icon may be up to 256KiB in size. + operationId: changeProjectIcon + tags: + - projects + parameters: + - description: Image extension + in: query + name: ext + required: true + schema: + type: string + enum: [png, jpg, jpeg, bmp, gif, webp, svg, svgz, rgb] + requestBody: + $ref: '#/components/requestBodies/Image' + security: + - TokenAuth: ['PROJECT_WRITE'] + responses: + '204': + description: Expected response to a valid request + '400': + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + delete: + summary: Delete project's icon + operationId: deleteProjectIcon + tags: + - projects + security: + - TokenAuth: ['PROJECT_WRITE'] + responses: + '204': + description: Expected response to a valid request + '400': + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + /project/{id|slug}/check: + parameters: + - $ref: '#/components/parameters/ProjectIdentifier' + get: + summary: Check project slug/ID validity + operationId: checkProjectValidity + tags: + - projects + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectIdentifier' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + /project/{id|slug}/gallery: + parameters: + - $ref: '#/components/parameters/ProjectIdentifier' + post: + summary: Add a gallery image + description: Modrinth allows you to upload files of up to 5MiB to a project's gallery. + operationId: addGalleryImage + tags: + - projects + security: + - TokenAuth: ['PROJECT_WRITE'] + parameters: + - description: Image extension + in: query + name: ext + required: true + schema: + type: string + enum: [png, jpg, jpeg, bmp, gif, webp, svg, svgz, rgb] + - description: Whether an image is featured + in: query + name: featured + required: true + schema: + type: boolean + - description: Title of the image + in: query + name: title + schema: + type: string + - description: Description of the image + in: query + name: description + schema: + type: string + - description: Ordering of the image + in: query + name: ordering + schema: + type: integer + requestBody: + $ref: '#/components/requestBodies/Image' + responses: + '204': + description: Expected response to a valid request + '400': + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + patch: + summary: Modify a gallery image + operationId: modifyGalleryImage + tags: + - projects + security: + - TokenAuth: ['PROJECT_WRITE'] + parameters: + - description: URL link of the image to modify + in: query + name: url + required: true + schema: + type: string + format: uri + - description: Whether the image is featured + in: query + name: featured + schema: + type: boolean + - description: New title of the image + in: query + name: title + schema: + type: string + - description: New description of the image + in: query + name: description + schema: + type: string + - description: New ordering of the image + in: query + name: ordering + schema: + type: integer + responses: + '204': + description: Expected response to a valid request + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + delete: + summary: Delete a gallery image + operationId: deleteGalleryImage + tags: + - projects + security: + - TokenAuth: ['PROJECT_WRITE'] + parameters: + - description: URL link of the image to delete + in: query + name: url + required: true + schema: + type: string + format: uri + responses: + '204': + description: Expected response to a valid request + '400': + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + /project/{id|slug}/dependencies: + parameters: + - $ref: '#/components/parameters/ProjectIdentifier' + get: + summary: Get all of a project's dependencies + operationId: getDependencies + tags: + - projects + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectDependencyList' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + /project/{id|slug}/follow: + parameters: + - $ref: '#/components/parameters/ProjectIdentifier' + post: + summary: Follow a project + operationId: followProject + tags: + - projects + security: + - TokenAuth: ['USER_WRITE'] + responses: + '204': + description: Expected response to a valid request + '400': + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + delete: + summary: Unfollow a project + operationId: unfollowProject + tags: + - projects + security: + - TokenAuth: ['USER_WRITE'] + responses: + '204': + description: Expected response to a valid request + '400': + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + /project/{id|slug}/schedule: + parameters: + - $ref: '#/components/parameters/ProjectIdentifier' + post: + summary: Schedule a project + operationId: scheduleProject + tags: + - projects + security: + - TokenAuth: ['PROJECT_WRITE'] + requestBody: + description: Information about date and requested status + content: + application/json: + schema: + $ref: '#/components/schemas/Schedule' + responses: + '204': + description: Expected response to a valid request + '400': + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + # Version + /project/{id|slug}/version: + parameters: + - $ref: '#/components/parameters/ProjectIdentifier' + get: + summary: List project's versions + operationId: getProjectVersions + tags: + - versions + parameters: + - in: query + name: loaders + required: false + description: 'The types of loaders to filter for' + schema: + type: string + example: '["fabric"]' + - in: query + name: game_versions + required: false + description: 'The game versions to filter for' + schema: + type: string + example: '["1.18.1"]' + - in: query + name: featured + required: false + description: 'Allows to filter for featured or non-featured versions only' + schema: + type: boolean + - in: query + name: include_changelog + required: false + description: 'Allows you to toggle the inclusion of the changelog field in the response. It is highly recommended to use include_changelog=false in most cases unless you specifically need the changelog for all versions.' + schema: + type: boolean + default: true + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Version' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + /version/{id}: + parameters: + - $ref: '#/components/parameters/VersionIdentifier' + get: + summary: Get a version + operationId: getVersion + tags: + - versions + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Version' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + patch: + summary: Modify a version + operationId: modifyVersion + tags: + - versions + security: + - TokenAuth: ['VERSION_WRITE'] + requestBody: + description: 'Modified version fields' + content: + application/json: + schema: + $ref: '#/components/schemas/EditableVersion' + responses: + '204': + description: Expected response to a valid request + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + delete: + summary: Delete a version + operationId: deleteVersion + tags: + - versions + security: + - TokenAuth: ['VERSION_DELETE'] + responses: + '204': + description: Expected response to a valid request + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + /project/{id|slug}/version/{id|number}: + parameters: + - $ref: '#/components/parameters/ProjectIdentifier' + - name: id|number + in: path + required: true + description: The version ID or version number + schema: + type: string + example: [IIJJKKLL] + get: + summary: Get a version given a version number or ID + description: Please note that, if the version number provided matches multiple versions, only the **oldest matching version** will be returned. + operationId: getVersionFromIdOrNumber + tags: + - versions + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Version' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + /version: + post: + summary: Create a version + description: | + This route creates a version on an existing project. There must be at least one file attached to each new version, unless the new version's status is `draft`. `.mrpack`, `.jar`, `.zip`, and `.litemod` files are accepted. + + The request is a [multipart request](https://www.ietf.org/rfc/rfc2388.txt) with at least two form fields: one is `data`, which includes a JSON body with the version metadata as shown below, and at least one field containing an upload file. + + You can name the file parts anything you would like, but you must list each of the parts' names in `file_parts`, and optionally, provide one to use as the primary file in `primary_file`. + operationId: createVersion + tags: + - versions + security: + - TokenAuth: ['VERSION_CREATE'] + requestBody: + description: 'New version' + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/CreateVersionBody' + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Version' + '400': + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + /version/{id}/schedule: + parameters: + - $ref: '#/components/parameters/VersionIdentifier' + post: + summary: Schedule a version + operationId: scheduleVersion + tags: + - versions + security: + - TokenAuth: ['VERSION_WRITE'] + requestBody: + description: Information about date and requested status + content: + application/json: + schema: + $ref: '#/components/schemas/Schedule' + responses: + '204': + description: Expected response to a valid request + '400': + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + /versions: + parameters: + - in: query + name: ids + description: The IDs of the versions + schema: + type: string + example: '["AABBCCDD", "EEFFGGHH"]' + required: true + get: + summary: Get multiple versions + operationId: getVersions + tags: + - versions + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Version' + /version/{id}/file: + parameters: + - $ref: '#/components/parameters/VersionIdentifier' + post: + summary: Add files to version + description: Project files are attached. `.mrpack` and `.jar` files are accepted. + operationId: addFilesToVersion + tags: + - versions + security: + - TokenAuth: ['VERSION_WRITE'] + requestBody: + description: 'New version files' + content: + multipart/form-data: + schema: + type: object + properties: + data: + type: object + enum: + - {} + responses: + '204': + description: Expected response to a valid request + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + # Version file + /version_file/{hash}: + parameters: + - $ref: '#/components/parameters/FileHashIdentifier' + - $ref: '#/components/parameters/AlgorithmIdentifier' + get: + summary: Get version from hash + operationId: versionFromHash + tags: + - version-files + parameters: + - $ref: '#/components/parameters/MultipleHashQueryIdentifier' + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Version' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + delete: + summary: Delete a file from its hash + operationId: deleteFileFromHash + tags: + - version-files + security: + - TokenAuth: ['VERSION_WRITE'] + parameters: + - description: Version ID to delete the version from, if multiple files of the same hash exist + required: false + in: query + name: version_id + schema: + type: string + example: [IIJJKKLL] + responses: + '204': + description: Expected response to a valid request + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + /version_file/{hash}/update: + parameters: + - $ref: '#/components/parameters/FileHashIdentifier' + - $ref: '#/components/parameters/AlgorithmIdentifier' + post: + summary: Latest version of a project from a hash, loader(s), and game version(s) + operationId: getLatestVersionFromHash + tags: + - version-files + requestBody: + description: Parameters of the updated version requested + content: + application/json: + schema: + $ref: '#/components/schemas/GetLatestVersionFromHashBody' + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Version' + '400': + description: Request was invalid, see given error + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + /version_files: + post: + summary: Get versions from hashes + description: This is the same as [`/version_file/{hash}`](#operation/versionFromHash) except it accepts multiple hashes. + operationId: versionsFromHashes + tags: + - version-files + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/HashVersionMap' + '400': + description: Request was invalid, see given error + requestBody: + description: Hashes and algorithm of the versions requested + content: + application/json: + schema: + $ref: '#/components/schemas/HashList' + /version_files/update: + post: + summary: Latest versions of multiple project from hashes, loader(s), and game version(s) + description: This is the same as [`/version_file/{hash}/update`](#operation/getLatestVersionFromHash) except it accepts multiple hashes. + operationId: getLatestVersionsFromHashes + tags: + - version-files + requestBody: + description: Parameters of the updated version requested + content: + application/json: + schema: + $ref: '#/components/schemas/GetLatestVersionsFromHashesBody' + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/HashVersionMap' + '400': + description: Request was invalid, see given error + # TODO check this out? https://github.com/modrinth/labrinth/blob/ec80c2b9dbf0bae98eb41714d3455b98095563b7/src/routes/v2/version_file.rs#L381 + #/version_files/project: + # post: + # summary: Get projects from hashes + # operationId: projectsFromHashes + # tags: + # - version-files + # responses: + # "200": + # description: Expected response to a valid request + # content: + # application/json: + # schema: + # type: object + # properties: + # your_hash_here: + # $ref: '#/components/schemas/Project' + # "400": + # description: Input is invalid + # requestBody: + # description: Hashes and algorithm of the projects requested + # content: + # application/json: + # schema: + # type: object + # properties: + # hashes: + # type: array + # items: + # type: string + # example: [ ea0f38408102e4d2efd53c2cc11b88b711996b48d8922f76ea6abf731219c5bd1efe39ddf9cce77c54d49a62ff10fb685c00d2e4c524ab99d20f6296677ab2c4, 925a5c4899affa4098d997dfa4a4cb52c636d539e94bc489d1fa034218cb96819a70eb8b01647a39316a59fcfe223c1a8c05ed2e2ae5f4c1e75fa48f6af1c960 ] + # algorithm: + # type: string + # enum: [ sha1, sha512 ] + # example: sha512 + # required: + # - hashes + # - algorithm + # User + /user/{id|username}: + parameters: + - $ref: '#/components/parameters/UserIdentifier' + get: + summary: Get a user + operationId: getUser + tags: + - users + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + patch: + summary: Modify a user + operationId: modifyUser + tags: + - users + security: + - TokenAuth: ['USER_WRITE'] + requestBody: + description: 'Modified user fields' + content: + application/json: + schema: + $ref: '#/components/schemas/EditableUser' + responses: + '204': + description: Expected response to a valid request + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + /user: + get: + summary: Get user from authorization header + operationId: getUserFromAuth + tags: + - users + security: + - TokenAuth: ['USER_READ'] + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + /users: + parameters: + - in: query + name: ids + description: The IDs of the users + schema: + type: string + example: '["AABBCCDD", "EEFFGGHH"]' + required: true + get: + summary: Get multiple users + operationId: getUsers + tags: + - users + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + /user/{id|username}/icon: + parameters: + - $ref: '#/components/parameters/UserIdentifier' + patch: + summary: Change user's avatar + description: The new avatar may be up to 2MiB in size. + operationId: changeUserIcon + tags: + - users + requestBody: + $ref: '#/components/requestBodies/Image' + security: + - TokenAuth: ['USER_WRITE'] + responses: + '204': + description: Expected response to a valid request + '400': + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + delete: + summary: Remove user's avatar + operationId: deleteUserIcon + tags: + - users + security: + - TokenAuth: ['USER_WRITE'] + responses: + '204': + description: Expected response to a valid request + '400': + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + /user/{id|username}/projects: + parameters: + - $ref: '#/components/parameters/UserIdentifier' + get: + summary: Get user's projects + operationId: getUserProjects + tags: + - users + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Project' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + /user/{id|username}/follows: + parameters: + - $ref: '#/components/parameters/UserIdentifier' + get: + summary: Get user's followed projects + operationId: getFollowedProjects + tags: + - users + security: + - TokenAuth: ['USER_READ'] + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Project' + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + /user/{id|username}/payouts: + parameters: + - $ref: '#/components/parameters/UserIdentifier' + get: + summary: Get user's payout history + operationId: getPayoutHistory + tags: + - users + security: + - TokenAuth: ['PAYOUTS_READ'] + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/UserPayoutHistory' + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + post: + summary: Withdraw payout balance to PayPal or Venmo + operationId: withdrawPayout + description: 'Warning: certain amounts get withheld for fees. Please do not call this API endpoint without first acknowledging the warnings on the corresponding frontend page.' + tags: + - users + security: + - TokenAuth: ['PAYOUTS_WRITE'] + parameters: + - name: amount + in: query + description: Amount to withdraw + schema: + type: integer + required: true + responses: + '204': + description: Expected response to a valid request + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + # Notifications + /user/{id|username}/notifications: + parameters: + - $ref: '#/components/parameters/UserIdentifier' + get: + summary: Get user's notifications + operationId: getUserNotifications + tags: + - notifications + security: + - TokenAuth: ['NOTIFICATION_READ'] + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Notification' + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + /notification/{id}: + parameters: + - $ref: '#/components/parameters/NotificationIdentifier' + get: + summary: Get notification from ID + operationId: getNotification + tags: + - notifications + security: + - TokenAuth: ['NOTIFICATION_READ'] + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Notification' + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + patch: + summary: Mark notification as read + operationId: readNotification + tags: + - notifications + security: + - TokenAuth: ['NOTIFICATION_WRITE'] + responses: + '204': + description: Expected response to a valid request + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + delete: + summary: Delete notification + operationId: deleteNotification + tags: + - notifications + security: + - TokenAuth: ['NOTIFICATION_WRITE'] + responses: + '204': + description: Expected response to a valid request + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + /notifications: + parameters: + - in: query + name: ids + description: The IDs of the notifications + schema: + type: string + example: '["AABBCCDD", "EEFFGGHH"]' + required: true + get: + summary: Get multiple notifications + operationId: getNotifications + tags: + - notifications + security: + - TokenAuth: ['NOTIFICATION_READ'] + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Notification' + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + patch: + summary: Mark multiple notifications as read + operationId: readNotifications + tags: + - notifications + security: + - TokenAuth: ['NOTIFICATION_WRITE'] + responses: + '204': + description: Expected response to a valid request + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + delete: + summary: Delete multiple notifications + operationId: deleteNotifications + tags: + - notifications + security: + - TokenAuth: ['NOTIFICATION_WRITE'] + responses: + '204': + description: Expected response to a valid request + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + # Threads + /report: + post: + summary: Report a project, user, or version + description: Bring a project, user, or version to the attention of the moderators by reporting it. + operationId: submitReport + tags: + - threads + security: + - TokenAuth: ['REPORT_CREATE'] + requestBody: + description: The report to be sent + content: + application/json: + schema: + $ref: '#/components/schemas/CreatableReport' + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Report' + '400': + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + get: + summary: Get your open reports + operationId: getOpenReports + tags: + - threads + security: + - TokenAuth: ['REPORT_READ'] + parameters: + - in: query + name: count + schema: + type: integer + example: 100 + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Report' + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + /report/{id}: + parameters: + - $ref: '#/components/parameters/ReportIdentifier' + get: + summary: Get report from ID + operationId: getReport + tags: + - threads + security: + - TokenAuth: ['REPORT_READ'] + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Report' + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + patch: + summary: Modify a report + operationId: modifyReport + tags: + - threads + security: + - TokenAuth: ['REPORT_WRITE'] + requestBody: + description: What to modify about the report + content: + application/json: + schema: + type: object + properties: + body: + type: string + description: The contents of the report + example: This is the meat and potatoes of the report! + closed: + type: boolean + description: Whether the thread should be closed + responses: + '204': + description: Expected response to a valid request + '400': + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + /reports: + parameters: + - in: query + name: ids + description: The IDs of the reports + schema: + type: string + example: '["AABBCCDD", "EEFFGGHH"]' + required: true + get: + summary: Get multiple reports + operationId: getReports + tags: + - threads + security: + - TokenAuth: ['REPORT_READ'] + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Report' + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + /thread/{id}: + parameters: + - $ref: '#/components/parameters/ThreadIdentifier' + get: + summary: Get a thread + operationId: getThread + tags: + - threads + security: + - TokenAuth: ['THREAD_READ'] + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Thread' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + post: + summary: Send a text message to a thread + operationId: sendThreadMessage + tags: + - threads + security: + - TokenAuth: ['THREAD_WRITE'] + requestBody: + description: The message to be sent. Note that you only need the fields applicable for the `text` type. + content: + application/json: + schema: + $ref: '#/components/schemas/ThreadMessageBody' + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Thread' + '400': + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + /threads: + parameters: + - in: query + name: ids + description: The IDs of the threads + schema: + type: string + example: '["AABBCCDD", "EEFFGGHH"]' + required: true + get: + summary: Get multiple threads + operationId: getThreads + tags: + - threads + security: + - TokenAuth: ['THREAD_READ'] + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Thread' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + /message/{id}: + parameters: + - name: id + in: path + required: true + description: The ID of the message + schema: + type: string + example: [IIJJKKLL] + delete: + summary: Delete a thread message + operationId: deleteThreadMessage + tags: + - threads + security: + - TokenAuth: ['THREAD_WRITE'] + responses: + '204': + description: Expected response to a valid request + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + # Teams + /project/{id|slug}/members: + parameters: + - $ref: '#/components/parameters/ProjectIdentifier' + get: + summary: Get a project's team members + operationId: getProjectTeamMembers + tags: + - teams + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TeamMember' + description: An array of team members + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + /team/{id}/members: + parameters: + - $ref: '#/components/parameters/TeamIdentifier' + get: + summary: Get a team's members + operationId: getTeamMembers + tags: + - teams + security: + - TokenAuth: ['PROJECT_READ'] + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TeamMember' + description: An array of team members + post: + summary: Add a user to a team + operationId: addTeamMember + tags: + - teams + security: + - TokenAuth: ['PROJECT_WRITE'] + requestBody: + description: User to be added (must be the ID, usernames cannot be used here) + content: + application/json: + schema: + $ref: '#/components/schemas/UserIdentifier' + responses: + '204': + description: Expected response to a valid request + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + /teams: + parameters: + - in: query + name: ids + description: The IDs of the teams + schema: + type: string + example: '["AABBCCDD", "EEFFGGHH"]' + required: true + get: + summary: Get the members of multiple teams + operationId: getTeams + tags: + - teams + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + type: array + items: + $ref: '#/components/schemas/TeamMember' + /team/{id}/join: + parameters: + - $ref: '#/components/parameters/TeamIdentifier' + post: + summary: Join a team + operationId: joinTeam + tags: + - teams + security: + - TokenAuth: ['PROJECT_WRITE'] + responses: + '204': + description: Expected response to a valid request + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + /team/{id}/members/{id|username}: + parameters: + - $ref: '#/components/parameters/TeamIdentifier' + - $ref: '#/components/parameters/UserIdentifier' + patch: + summary: Modify a team member's information + operationId: modifyTeamMember + tags: + - teams + security: + - TokenAuth: ['PROJECT_WRITE'] + requestBody: + description: Contents to be modified + content: + application/json: + schema: + $ref: '#/components/schemas/ModifyTeamMemberBody' + responses: + '204': + description: Expected response to a valid request + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + delete: + summary: Remove a member from a team + operationId: deleteTeamMember + tags: + - teams + security: + - TokenAuth: ['PROJECT_WRITE'] + responses: + '204': + description: Expected response to a valid request + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + /team/{id}/owner: + parameters: + - $ref: '#/components/parameters/TeamIdentifier' + patch: + summary: Transfer team's ownership to another user + operationId: transferTeamOwnership + tags: + - teams + security: + - TokenAuth: ['PROJECT_WRITE'] + requestBody: + description: New owner's ID + content: + application/json: + schema: + $ref: '#/components/schemas/UserIdentifier' + responses: + '204': + description: Expected response to a valid request + '401': + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + '404': + description: The requested item(s) were not found or no authorization to access the requested item(s) + # Tags + /tag/category: + get: + summary: Get a list of categories + description: Gets an array of categories, their icons, and applicable project types + operationId: categoryList + tags: + - tags + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CategoryTag' + /tag/loader: + get: + summary: Get a list of loaders + description: Gets an array of loaders, their icons, and supported project types + operationId: loaderList + tags: + - tags + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/LoaderTag' + /tag/game_version: + get: + summary: Get a list of game versions + description: Gets an array of game versions and information about them + operationId: versionList + tags: + - tags + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/GameVersionTag' + /tag/license: + get: + deprecated: true + summary: Get a list of licenses + description: Deprecated - simply use SPDX IDs. + operationId: licenseList + tags: + - tags + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/LicenseTag' + /tag/license/{id}: + parameters: + - name: id + in: path + required: true + description: The license ID to get the text of + schema: + type: string + example: [LGPL-3.0-or-later] + get: + summary: Get the text and title of a license + operationId: licenseText + tags: + - tags + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/License' + '400': + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + /tag/donation_platform: + get: + summary: Get a list of donation platforms + description: Gets an array of donation platforms and information about them + operationId: donationPlatformList + tags: + - tags + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DonationPlatformTag' + /tag/report_type: + get: + summary: Get a list of report types + description: Gets an array of valid report types + operationId: reportTypeList + tags: + - tags + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + type: string + example: [spam, copyright, inappropriate, malicious, name-squatting, other] + /tag/project_type: + get: + summary: Get a list of project types + description: Gets an array of valid project types + operationId: projectTypeList + tags: + - tags + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + type: string + example: [mod, modpack, resourcepack, shader] + /tag/side_type: + get: + summary: Get a list of side types + description: Gets an array of valid side types + operationId: sideTypeList + tags: + - tags + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + type: string + example: [required, optional, unsupported, unknown] + # Miscellaneous + /updates/{id|slug}/forge_updates.json: + parameters: + - $ref: '#/components/parameters/ProjectIdentifier' + - name: neoforge + in: query + description: Whether to include NeoForge versions. Can be `only` (NeoForge-only versions), `include` (both Forge and NeoForge versions), or omitted (Forge-only versions). + schema: + type: string + enum: [only, include] + example: include + servers: + - url: https://api.modrinth.com + description: Production server + - url: https://staging-api.modrinth.com + description: Staging server + get: + summary: Forge Updates JSON file + operationId: forgeUpdates + description: | + If you're a Forge mod developer, your Modrinth mods have an automatically generated `updates.json` using the + [Forge Update Checker](https://docs.minecraftforge.net/en/latest/misc/updatechecker/). + + The only setup is to insert the URL into the `[[mods]]` section of your `mods.toml` file as such: + + ```toml + [[mods]] + # the other stuff here - ID, version, display name, etc. + updateJSONURL = "https://api.modrinth.com/updates/{slug|ID}/forge_updates.json" + ``` + + Replace `{slug|id}` with the slug or ID of your project. + + Modrinth will handle the rest! When you update your mod, Forge will notify your users that their copy of your mod is out of date. + + Make sure that the version format you use for your Modrinth releases is the same as the version format you use in your `mods.toml`. + If you use a format such as `1.2.3-forge` or `1.2.3+1.19` with your Modrinth releases but your `mods.toml` only has `1.2.3`, + the update checker may not function properly. + + If you're using NeoForge, NeoForge versions will, by default, not appear in the default URL. + You will need to add `?neoforge=only` to show your NeoForge-only versions, or `?neoforge=include` for both. + + ```toml + [[mods]] + # the other stuff here - ID, version, display name, etc. + updateJSONURL = "https://api.modrinth.com/updates/{slug|ID}/forge_updates.json?neoforge=only" + ``` + tags: + - misc + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/ForgeUpdates' + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + /statistics: + get: + summary: Various statistics about this Modrinth instance + operationId: statistics + tags: + - misc + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Statistics' diff --git a/packages/modrinth/src/category.ts b/packages/modrinth/src/category.ts new file mode 100644 index 000000000..a09ac2ecc --- /dev/null +++ b/packages/modrinth/src/category.ts @@ -0,0 +1,4 @@ +/** + * Re-export the shared category system from sdk-core. + */ +export * from "@distilled.cloud/core/category"; diff --git a/packages/modrinth/src/client.ts b/packages/modrinth/src/client.ts new file mode 100644 index 000000000..0378abe79 --- /dev/null +++ b/packages/modrinth/src/client.ts @@ -0,0 +1,98 @@ +/** + * Modrinth API Client. + * + * Wraps the shared REST client from sdk-core with Modrinth-specific + * error matching and credential handling. + */ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { makeAPI } from "@distilled.cloud/core/client"; +import { parseRetryAfterForStatus } from "@distilled.cloud/core/retry-after"; +import { + HTTP_STATUS_MAP, + UnknownModrinthError, + ModrinthParseError, +} from "./errors.ts"; + +// Re-export for backwards compatibility +export { UnknownModrinthError } from "./errors.ts"; +import { Credentials } from "./credentials.ts"; +import { Retry } from "./retry.ts"; + +/** + * Modrinth API error response shape. + * + * Modrinth returns errors as `{ error: string, description: string }`, e.g.: + * + * ```json + * { "error": "invalid_input", "description": "Error while parsing multipart payload" } + * { "error": "unauthorized", "description": "Authentication Error: Invalid Authentication Credentials" } + * ``` + */ +const ApiErrorResponse = Schema.Struct({ + error: Schema.optional(Schema.String), + description: Schema.optional(Schema.String), +}); + +const matchError = ( + status: number, + errorBody: unknown, + _errors?: readonly unknown[], + headers?: Record, +): Effect.Effect => { + const ErrorClass = (HTTP_STATUS_MAP as any)[status]; + let message = ""; + let code: string | undefined; + try { + const parsed = Schema.decodeUnknownSync(ApiErrorResponse)(errorBody); + message = parsed.description ?? parsed.error ?? ""; + code = parsed.error; + } catch { + // Non-JSON or empty body — fall through. Modrinth returns empty bodies for + // some 404s (e.g. unknown project slug), so we must still surface the + // typed HTTP error class based on the status code. + } + if (ErrorClass) { + return Effect.fail( + new ErrorClass({ + message, + retryAfter: parseRetryAfterForStatus(status, headers), + }), + ); + } + return Effect.fail( + new UnknownModrinthError({ + code, + message: message || undefined, + body: errorBody, + }), + ); +}; + +/** + * Modrinth API client. + * + * Authentication: Modrinth uses an `Authorization` header containing the raw + * personal access token (or OAuth2 token) — there is NO `Bearer ` prefix. For + * unauthenticated requests (most read endpoints work without a token), we omit + * the header entirely. + * + * `User-Agent` is required on every request; Modrinth may rate-limit or block + * traffic that only identifies the underlying HTTP client. + */ +export const API = makeAPI({ + credentials: Credentials as any, + getBaseUrl: (creds: any) => creds.apiBaseUrl, + getAuthHeaders: (creds: any): Record => { + const headers: Record = { + "User-Agent": creds.userAgent, + }; + if (creds.apiKey) { + headers["Authorization"] = creds.apiKey; + } + return headers; + }, + matchError, + ParseError: ModrinthParseError as any, + retry: Retry as any, +}); diff --git a/packages/modrinth/src/credentials.ts b/packages/modrinth/src/credentials.ts new file mode 100644 index 000000000..d923b6482 --- /dev/null +++ b/packages/modrinth/src/credentials.ts @@ -0,0 +1,58 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { ConfigError } from "@distilled.cloud/core/errors"; + +export const DEFAULT_API_BASE_URL = "https://api.modrinth.com/v2"; + +/** + * A `User-Agent` header is required by the Modrinth API. Modrinth recommends + * `// ()`. We default to a + * generic identifier but strongly recommend users override this via env. + * + * See https://docs.modrinth.com/api/ ("User Agents"). + */ +export const DEFAULT_USER_AGENT = "distilled.cloud/modrinth-sdk"; + +export interface Config { + /** + * Modrinth personal access token (PAT) or OAuth2 access token. + * + * Sent verbatim in the `Authorization` header (no `Bearer ` prefix). PATs are + * generated at https://modrinth.com/settings/account. + * + * Optional: many read endpoints (browsing public projects/versions) work + * without a token. Provide one for any operation that creates or modifies + * data, or that touches private resources. + */ + readonly apiKey?: string; + readonly apiBaseUrl: string; + /** + * Value sent in the `User-Agent` header. Modrinth requires a + * uniquely-identifying user agent and may block traffic that only identifies + * the underlying HTTP client. + */ + readonly userAgent: string; +} + +export class Credentials extends Context.Service()( + "ModrinthCredentials", +) {} + +export const CredentialsFromEnv = Layer.effect( + Credentials, + Effect.gen(function* () { + const apiKey = process.env.MODRINTH_API_KEY; + const apiBaseUrl = + process.env.MODRINTH_API_BASE_URL ?? DEFAULT_API_BASE_URL; + const userAgent = process.env.MODRINTH_USER_AGENT ?? DEFAULT_USER_AGENT; + + if (!userAgent) { + return yield* new ConfigError({ + message: "MODRINTH_USER_AGENT environment variable is required", + }); + } + + return { apiKey, apiBaseUrl, userAgent }; + }), +); diff --git a/packages/modrinth/src/errors.ts b/packages/modrinth/src/errors.ts new file mode 100644 index 000000000..ddd03386f --- /dev/null +++ b/packages/modrinth/src/errors.ts @@ -0,0 +1,47 @@ +/** + * Modrinth-specific error types. + * + * Re-exports common HTTP errors from sdk-core and adds Modrinth-specific + * error matching and API error types. + */ +export { + BadGateway, + BadRequest, + Conflict, + ConfigError, + Forbidden, + GatewayTimeout, + InternalServerError, + Locked, + NotFound, + ServiceUnavailable, + TooManyRequests, + Unauthorized, + UnprocessableEntity, + HTTP_STATUS_MAP, + DEFAULT_ERRORS, + API_ERRORS, +} from "@distilled.cloud/core/errors"; +export type { DefaultErrors } from "@distilled.cloud/core/errors"; + +import * as Schema from "effect/Schema"; +import * as Category from "@distilled.cloud/core/category"; + +// Unknown Modrinth error - returned when an error code is not recognized +export class UnknownModrinthError extends Schema.TaggedErrorClass()( + "UnknownModrinthError", + { + code: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), + body: Schema.Unknown, + }, +).pipe(Category.withServerError) {} + +// Schema parse error wrapper +export class ModrinthParseError extends Schema.TaggedErrorClass()( + "ModrinthParseError", + { + body: Schema.Unknown, + cause: Schema.Unknown, + }, +).pipe(Category.withParseError) {} diff --git a/packages/modrinth/src/index.ts b/packages/modrinth/src/index.ts new file mode 100644 index 000000000..4b2cafd26 --- /dev/null +++ b/packages/modrinth/src/index.ts @@ -0,0 +1,16 @@ +/** + * Modrinth SDK for Effect + * + * @example + * \`\`\`ts + * import * as Modrinth from "@distilled.cloud/modrinth"; + * \`\`\` + */ +export * from "./credentials.ts"; +export * as Category from "./category.ts"; +export * as T from "./traits.ts"; +export * as Retry from "./retry.ts"; +export { API } from "./client.ts"; +export * from "./errors.ts"; +export * from "./operations/index.ts"; +export { SensitiveString, SensitiveNullableString } from "./sensitive.ts"; diff --git a/packages/modrinth/src/operations/addFilesToVersion.ts b/packages/modrinth/src/operations/addFilesToVersion.ts new file mode 100644 index 000000000..b597d3310 --- /dev/null +++ b/packages/modrinth/src/operations/addFilesToVersion.ts @@ -0,0 +1,37 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, NotFound } from "../errors.ts"; + +// Input Schema +export const AddFilesToVersionInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct( + { + id: Schema.String.pipe(T.PathParam()), + data: Schema.optional(Schema.Literals(["[object Object]"])), + }, +).pipe( + T.Http({ + method: "POST", + path: "/version/{id}/file", + contentType: "multipart", + }), +); +export type AddFilesToVersionInput = typeof AddFilesToVersionInput.Type; + +// Output Schema +export const AddFilesToVersionOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type AddFilesToVersionOutput = typeof AddFilesToVersionOutput.Type; + +// The operation +/** + * Add files to version + * + * Project files are attached. `.mrpack` and `.jar` files are accepted. + * + * @param id - The ID of the version + */ +export const addFilesToVersion = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: AddFilesToVersionInput, + outputSchema: AddFilesToVersionOutput, + errors: [BadRequest, NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/addGalleryImage.ts b/packages/modrinth/src/operations/addGalleryImage.ts new file mode 100644 index 000000000..dd69682c4 --- /dev/null +++ b/packages/modrinth/src/operations/addGalleryImage.ts @@ -0,0 +1,48 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, NotFound } from "../errors.ts"; + +// Input Schema +export const AddGalleryImageInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id_or_slug: Schema.String.pipe(T.PathParam()), + ext: Schema.Literals([ + "png", + "jpg", + "jpeg", + "bmp", + "gif", + "webp", + "svg", + "svgz", + "rgb", + ]), + featured: Schema.Boolean, + title: Schema.optional(Schema.String), + description: Schema.optional(Schema.String), + ordering: Schema.optional(Schema.Number), +}).pipe(T.Http({ method: "POST", path: "/project/{id_or_slug}/gallery" })); +export type AddGalleryImageInput = typeof AddGalleryImageInput.Type; + +// Output Schema +export const AddGalleryImageOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type AddGalleryImageOutput = typeof AddGalleryImageOutput.Type; + +// The operation +/** + * Add a gallery image + * + * Modrinth allows you to upload files of up to 5MiB to a project's gallery. + * + * @param id_or_slug - The ID or slug of the project + * @param ext - Image extension + * @param featured - Whether an image is featured + * @param title - Title of the image + * @param description - Description of the image + * @param ordering - Ordering of the image + */ +export const addGalleryImage = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: AddGalleryImageInput, + outputSchema: AddGalleryImageOutput, + errors: [BadRequest, NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/addTeamMember.ts b/packages/modrinth/src/operations/addTeamMember.ts new file mode 100644 index 000000000..bdf7fdb42 --- /dev/null +++ b/packages/modrinth/src/operations/addTeamMember.ts @@ -0,0 +1,27 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, NotFound } from "../errors.ts"; + +// Input Schema +export const AddTeamMemberInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id: Schema.String.pipe(T.PathParam()), + user_id: Schema.String, +}).pipe(T.Http({ method: "POST", path: "/team/{id}/members" })); +export type AddTeamMemberInput = typeof AddTeamMemberInput.Type; + +// Output Schema +export const AddTeamMemberOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type AddTeamMemberOutput = typeof AddTeamMemberOutput.Type; + +// The operation +/** + * Add a user to a team + * + * @param id - The ID of the team + */ +export const addTeamMember = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: AddTeamMemberInput, + outputSchema: AddTeamMemberOutput, + errors: [BadRequest, NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/categoryList.ts b/packages/modrinth/src/operations/categoryList.ts new file mode 100644 index 000000000..1eec856da --- /dev/null +++ b/packages/modrinth/src/operations/categoryList.ts @@ -0,0 +1,31 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; + +// Input Schema +export const CategoryListInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct( + {}, +).pipe(T.Http({ method: "GET", path: "/tag/category" })); +export type CategoryListInput = typeof CategoryListInput.Type; + +// Output Schema +export const CategoryListOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Array( + Schema.Struct({ + icon: Schema.String, + name: Schema.String, + project_type: Schema.String, + header: Schema.String, + }), +); +export type CategoryListOutput = typeof CategoryListOutput.Type; + +// The operation +/** + * Get a list of categories + * + * Gets an array of categories, their icons, and applicable project types + */ +export const categoryList = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: CategoryListInput, + outputSchema: CategoryListOutput, +})); diff --git a/packages/modrinth/src/operations/changeProjectIcon.ts b/packages/modrinth/src/operations/changeProjectIcon.ts new file mode 100644 index 000000000..aa27eacaa --- /dev/null +++ b/packages/modrinth/src/operations/changeProjectIcon.ts @@ -0,0 +1,42 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest } from "../errors.ts"; + +// Input Schema +export const ChangeProjectIconInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct( + { + id_or_slug: Schema.String.pipe(T.PathParam()), + ext: Schema.Literals([ + "png", + "jpg", + "jpeg", + "bmp", + "gif", + "webp", + "svg", + "svgz", + "rgb", + ]), + }, +).pipe(T.Http({ method: "PATCH", path: "/project/{id_or_slug}/icon" })); +export type ChangeProjectIconInput = typeof ChangeProjectIconInput.Type; + +// Output Schema +export const ChangeProjectIconOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type ChangeProjectIconOutput = typeof ChangeProjectIconOutput.Type; + +// The operation +/** + * Change project's icon + * + * The new icon may be up to 256KiB in size. + * + * @param id_or_slug - The ID or slug of the project + * @param ext - Image extension + */ +export const changeProjectIcon = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: ChangeProjectIconInput, + outputSchema: ChangeProjectIconOutput, + errors: [BadRequest] as const, +})); diff --git a/packages/modrinth/src/operations/changeUserIcon.ts b/packages/modrinth/src/operations/changeUserIcon.ts new file mode 100644 index 000000000..bed79526e --- /dev/null +++ b/packages/modrinth/src/operations/changeUserIcon.ts @@ -0,0 +1,28 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, NotFound } from "../errors.ts"; + +// Input Schema +export const ChangeUserIconInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id_or_username: Schema.String.pipe(T.PathParam()), +}).pipe(T.Http({ method: "PATCH", path: "/user/{id_or_username}/icon" })); +export type ChangeUserIconInput = typeof ChangeUserIconInput.Type; + +// Output Schema +export const ChangeUserIconOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type ChangeUserIconOutput = typeof ChangeUserIconOutput.Type; + +// The operation +/** + * Change user's avatar + * + * The new avatar may be up to 2MiB in size. + * + * @param id_or_username - The ID or username of the user + */ +export const changeUserIcon = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: ChangeUserIconInput, + outputSchema: ChangeUserIconOutput, + errors: [BadRequest, NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/checkProjectValidity.ts b/packages/modrinth/src/operations/checkProjectValidity.ts new file mode 100644 index 000000000..c77985b47 --- /dev/null +++ b/packages/modrinth/src/operations/checkProjectValidity.ts @@ -0,0 +1,32 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { NotFound } from "../errors.ts"; + +// Input Schema +export const CheckProjectValidityInput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id_or_slug: Schema.String.pipe(T.PathParam()), + }).pipe(T.Http({ method: "GET", path: "/project/{id_or_slug}/check" })); +export type CheckProjectValidityInput = typeof CheckProjectValidityInput.Type; + +// Output Schema +export const CheckProjectValidityOutput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id: Schema.optional(Schema.String), + }); +export type CheckProjectValidityOutput = typeof CheckProjectValidityOutput.Type; + +// The operation +/** + * Check project slug/ID validity + * + * @param id_or_slug - The ID or slug of the project + */ +export const checkProjectValidity = /*@__PURE__*/ /*#__PURE__*/ API.make( + () => ({ + inputSchema: CheckProjectValidityInput, + outputSchema: CheckProjectValidityOutput, + errors: [NotFound] as const, + }), +); diff --git a/packages/modrinth/src/operations/createProject.ts b/packages/modrinth/src/operations/createProject.ts new file mode 100644 index 000000000..8d684057c --- /dev/null +++ b/packages/modrinth/src/operations/createProject.ts @@ -0,0 +1,149 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest } from "../errors.ts"; + +// Input Schema +export const CreateProjectInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + data: Schema.Struct({ + project_type: Schema.Literals(["mod", "modpack"]), + initial_versions: Schema.optional( + Schema.Array( + Schema.Struct({ + name: Schema.optional(Schema.String), + version_number: Schema.optional(Schema.String), + changelog: Schema.optional(Schema.NullOr(Schema.String)), + dependencies: Schema.optional( + Schema.Array( + Schema.Struct({ + version_id: Schema.optional(Schema.NullOr(Schema.String)), + project_id: Schema.optional(Schema.NullOr(Schema.String)), + file_name: Schema.optional(Schema.NullOr(Schema.String)), + dependency_type: Schema.Literals([ + "required", + "optional", + "incompatible", + "embedded", + ]), + }), + ), + ), + game_versions: Schema.optional(Schema.Array(Schema.String)), + version_type: Schema.optional( + Schema.Literals(["release", "beta", "alpha"]), + ), + loaders: Schema.optional(Schema.Array(Schema.String)), + featured: Schema.optional(Schema.Boolean), + status: Schema.optional( + Schema.Literals([ + "listed", + "archived", + "draft", + "unlisted", + "scheduled", + "unknown", + ]), + ), + requested_status: Schema.optional( + Schema.NullOr( + Schema.Literals(["listed", "archived", "draft", "unlisted"]), + ), + ), + primary_file: Schema.optional(Schema.Array(Schema.String)), + file_types: Schema.optional( + Schema.Array( + Schema.Struct({ + algorithm: Schema.String, + hash: Schema.String, + file_type: Schema.Struct({}), + }), + ), + ), + }), + ), + ), + is_draft: Schema.optional(Schema.Boolean), + gallery_items: Schema.optional( + Schema.Array( + Schema.NullOr( + Schema.Struct({ + item: Schema.optional(Schema.String), + featured: Schema.optional(Schema.Boolean), + title: Schema.optional(Schema.NullOr(Schema.String)), + description: Schema.optional(Schema.NullOr(Schema.String)), + ordering: Schema.optional(Schema.Number), + }), + ), + ), + ), + }), + icon: Schema.optional( + Schema.Literals([ + "*.png", + "*.jpg", + "*.jpeg", + "*.bmp", + "*.gif", + "*.webp", + "*.svg", + "*.svgz", + "*.rgb", + ]), + ), +}).pipe(T.Http({ method: "POST", path: "/project", contentType: "multipart" })); +export type CreateProjectInput = typeof CreateProjectInput.Type; + +// Output Schema +export const CreateProjectOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id: Schema.String, + team: Schema.String, + body_url: Schema.optional(Schema.NullOr(Schema.String)), + moderator_message: Schema.optional( + Schema.NullOr( + Schema.Struct({ + message: Schema.optional(Schema.String), + body: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + published: Schema.String, + updated: Schema.String, + approved: Schema.optional(Schema.NullOr(Schema.String)), + queued: Schema.optional(Schema.NullOr(Schema.String)), + followers: Schema.Number, + license: Schema.optional( + Schema.Struct({ + id: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), + url: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + versions: Schema.optional(Schema.Array(Schema.String)), + game_versions: Schema.optional(Schema.Array(Schema.String)), + loaders: Schema.optional(Schema.Array(Schema.String)), + gallery: Schema.optional( + Schema.Array( + Schema.NullOr( + Schema.Struct({ + url: Schema.String, + featured: Schema.Boolean, + title: Schema.optional(Schema.NullOr(Schema.String)), + description: Schema.optional(Schema.NullOr(Schema.String)), + created: Schema.String, + ordering: Schema.optional(Schema.Number), + }), + ), + ), + ), +}); +export type CreateProjectOutput = typeof CreateProjectOutput.Type; + +// The operation +/** + * Create a project + */ +export const createProject = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: CreateProjectInput, + outputSchema: CreateProjectOutput, + errors: [BadRequest] as const, +})); diff --git a/packages/modrinth/src/operations/createVersion.ts b/packages/modrinth/src/operations/createVersion.ts new file mode 100644 index 000000000..8259f23ad --- /dev/null +++ b/packages/modrinth/src/operations/createVersion.ts @@ -0,0 +1,122 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest } from "../errors.ts"; + +// Input Schema +export const CreateVersionInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + data: Schema.Struct({ + name: Schema.String, + version_number: Schema.String, + changelog: Schema.optional(Schema.NullOr(Schema.String)), + dependencies: Schema.Array( + Schema.Struct({ + version_id: Schema.optional(Schema.NullOr(Schema.String)), + project_id: Schema.optional(Schema.NullOr(Schema.String)), + file_name: Schema.optional(Schema.NullOr(Schema.String)), + dependency_type: Schema.Literals([ + "required", + "optional", + "incompatible", + "embedded", + ]), + }), + ), + game_versions: Schema.Array(Schema.String), + version_type: Schema.Literals(["release", "beta", "alpha"]), + loaders: Schema.Array(Schema.String), + featured: Schema.Boolean, + status: Schema.optional( + Schema.Literals([ + "listed", + "archived", + "draft", + "unlisted", + "scheduled", + "unknown", + ]), + ), + requested_status: Schema.optional( + Schema.NullOr( + Schema.Literals(["listed", "archived", "draft", "unlisted"]), + ), + ), + project_id: Schema.String, + file_parts: Schema.Array(Schema.String), + primary_file: Schema.optional(Schema.String), + }), +}).pipe(T.Http({ method: "POST", path: "/version", contentType: "multipart" })); +export type CreateVersionInput = typeof CreateVersionInput.Type; + +// Output Schema +export const CreateVersionOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + name: Schema.String, + version_number: Schema.String, + changelog: Schema.optional(Schema.NullOr(Schema.String)), + dependencies: Schema.optional( + Schema.Array( + Schema.Struct({ + version_id: Schema.optional(Schema.NullOr(Schema.String)), + project_id: Schema.optional(Schema.NullOr(Schema.String)), + file_name: Schema.optional(Schema.NullOr(Schema.String)), + dependency_type: Schema.Literals([ + "required", + "optional", + "incompatible", + "embedded", + ]), + }), + ), + ), + game_versions: Schema.Array(Schema.String), + version_type: Schema.Literals(["release", "beta", "alpha"]), + loaders: Schema.Array(Schema.String), + featured: Schema.Boolean, + status: Schema.optional( + Schema.Literals([ + "listed", + "archived", + "draft", + "unlisted", + "scheduled", + "unknown", + ]), + ), + requested_status: Schema.optional( + Schema.NullOr(Schema.Literals(["listed", "archived", "draft", "unlisted"])), + ), + id: Schema.String, + project_id: Schema.String, + author_id: Schema.String, + date_published: Schema.String, + downloads: Schema.Number, + changelog_url: Schema.optional(Schema.NullOr(Schema.String)), + files: Schema.Array( + Schema.Struct({ + hashes: Schema.Struct({ + sha512: Schema.optional(Schema.String), + sha1: Schema.optional(Schema.String), + }), + url: Schema.String, + filename: Schema.String, + primary: Schema.Boolean, + size: Schema.Number, + file_type: Schema.optional(Schema.Struct({})), + }), + ), +}); +export type CreateVersionOutput = typeof CreateVersionOutput.Type; + +// The operation +/** + * Create a version + * + * This route creates a version on an existing project. There must be at least one file attached to each new version, unless the new version's status is `draft`. `.mrpack`, `.jar`, `.zip`, and `.litemod` files are accepted. + * The request is a [multipart request](https://www.ietf.org/rfc/rfc2388.txt) with at least two form fields: one is `data`, which includes a JSON body with the version metadata as shown below, and at least one field containing an upload file. + * You can name the file parts anything you would like, but you must list each of the parts' names in `file_parts`, and optionally, provide one to use as the primary file in `primary_file`. + */ +export const createVersion = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: CreateVersionInput, + outputSchema: CreateVersionOutput, + errors: [BadRequest] as const, +})); diff --git a/packages/modrinth/src/operations/deleteFileFromHash.ts b/packages/modrinth/src/operations/deleteFileFromHash.ts new file mode 100644 index 000000000..f38cccffc --- /dev/null +++ b/packages/modrinth/src/operations/deleteFileFromHash.ts @@ -0,0 +1,31 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { NotFound } from "../errors.ts"; + +// Input Schema +export const DeleteFileFromHashInput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + hash: Schema.String.pipe(T.PathParam()), + algorithm: Schema.Literals(["sha1", "sha512"]), + version_id: Schema.optional(Schema.String), + }).pipe(T.Http({ method: "DELETE", path: "/version_file/{hash}" })); +export type DeleteFileFromHashInput = typeof DeleteFileFromHashInput.Type; + +// Output Schema +export const DeleteFileFromHashOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type DeleteFileFromHashOutput = typeof DeleteFileFromHashOutput.Type; + +// The operation +/** + * Delete a file from its hash + * + * @param hash - The hash of the file, considering its byte content, and encoded in hexadecimal + * @param algorithm - The algorithm of the hash + * @param version_id - Version ID to delete the version from, if multiple files of the same hash exist + */ +export const deleteFileFromHash = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: DeleteFileFromHashInput, + outputSchema: DeleteFileFromHashOutput, + errors: [NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/deleteGalleryImage.ts b/packages/modrinth/src/operations/deleteGalleryImage.ts new file mode 100644 index 000000000..5b94fa1b7 --- /dev/null +++ b/packages/modrinth/src/operations/deleteGalleryImage.ts @@ -0,0 +1,29 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest } from "../errors.ts"; + +// Input Schema +export const DeleteGalleryImageInput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id_or_slug: Schema.String.pipe(T.PathParam()), + url: Schema.String, + }).pipe(T.Http({ method: "DELETE", path: "/project/{id_or_slug}/gallery" })); +export type DeleteGalleryImageInput = typeof DeleteGalleryImageInput.Type; + +// Output Schema +export const DeleteGalleryImageOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type DeleteGalleryImageOutput = typeof DeleteGalleryImageOutput.Type; + +// The operation +/** + * Delete a gallery image + * + * @param id_or_slug - The ID or slug of the project + * @param url - URL link of the image to delete + */ +export const deleteGalleryImage = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: DeleteGalleryImageInput, + outputSchema: DeleteGalleryImageOutput, + errors: [BadRequest] as const, +})); diff --git a/packages/modrinth/src/operations/deleteNotification.ts b/packages/modrinth/src/operations/deleteNotification.ts new file mode 100644 index 000000000..69226a614 --- /dev/null +++ b/packages/modrinth/src/operations/deleteNotification.ts @@ -0,0 +1,27 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, NotFound } from "../errors.ts"; + +// Input Schema +export const DeleteNotificationInput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id: Schema.String.pipe(T.PathParam()), + }).pipe(T.Http({ method: "DELETE", path: "/notification/{id}" })); +export type DeleteNotificationInput = typeof DeleteNotificationInput.Type; + +// Output Schema +export const DeleteNotificationOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type DeleteNotificationOutput = typeof DeleteNotificationOutput.Type; + +// The operation +/** + * Delete notification + * + * @param id - The ID of the notification + */ +export const deleteNotification = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: DeleteNotificationInput, + outputSchema: DeleteNotificationOutput, + errors: [BadRequest, NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/deleteNotifications.ts b/packages/modrinth/src/operations/deleteNotifications.ts new file mode 100644 index 000000000..31ba44ff1 --- /dev/null +++ b/packages/modrinth/src/operations/deleteNotifications.ts @@ -0,0 +1,28 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, NotFound } from "../errors.ts"; + +// Input Schema +export const DeleteNotificationsInput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + ids: Schema.String, + }).pipe(T.Http({ method: "DELETE", path: "/notifications" })); +export type DeleteNotificationsInput = typeof DeleteNotificationsInput.Type; + +// Output Schema +export const DeleteNotificationsOutput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type DeleteNotificationsOutput = typeof DeleteNotificationsOutput.Type; + +// The operation +/** + * Delete multiple notifications + * + * @param ids - The IDs of the notifications + */ +export const deleteNotifications = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: DeleteNotificationsInput, + outputSchema: DeleteNotificationsOutput, + errors: [BadRequest, NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/deleteProject.ts b/packages/modrinth/src/operations/deleteProject.ts new file mode 100644 index 000000000..9a98d5f9e --- /dev/null +++ b/packages/modrinth/src/operations/deleteProject.ts @@ -0,0 +1,26 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest } from "../errors.ts"; + +// Input Schema +export const DeleteProjectInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id_or_slug: Schema.String.pipe(T.PathParam()), +}).pipe(T.Http({ method: "DELETE", path: "/project/{id_or_slug}" })); +export type DeleteProjectInput = typeof DeleteProjectInput.Type; + +// Output Schema +export const DeleteProjectOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type DeleteProjectOutput = typeof DeleteProjectOutput.Type; + +// The operation +/** + * Delete a project + * + * @param id_or_slug - The ID or slug of the project + */ +export const deleteProject = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: DeleteProjectInput, + outputSchema: DeleteProjectOutput, + errors: [BadRequest] as const, +})); diff --git a/packages/modrinth/src/operations/deleteProjectIcon.ts b/packages/modrinth/src/operations/deleteProjectIcon.ts new file mode 100644 index 000000000..8bc66e42f --- /dev/null +++ b/packages/modrinth/src/operations/deleteProjectIcon.ts @@ -0,0 +1,28 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest } from "../errors.ts"; + +// Input Schema +export const DeleteProjectIconInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct( + { + id_or_slug: Schema.String.pipe(T.PathParam()), + }, +).pipe(T.Http({ method: "DELETE", path: "/project/{id_or_slug}/icon" })); +export type DeleteProjectIconInput = typeof DeleteProjectIconInput.Type; + +// Output Schema +export const DeleteProjectIconOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type DeleteProjectIconOutput = typeof DeleteProjectIconOutput.Type; + +// The operation +/** + * Delete project's icon + * + * @param id_or_slug - The ID or slug of the project + */ +export const deleteProjectIcon = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: DeleteProjectIconInput, + outputSchema: DeleteProjectIconOutput, + errors: [BadRequest] as const, +})); diff --git a/packages/modrinth/src/operations/deleteTeamMember.ts b/packages/modrinth/src/operations/deleteTeamMember.ts new file mode 100644 index 000000000..26805ca67 --- /dev/null +++ b/packages/modrinth/src/operations/deleteTeamMember.ts @@ -0,0 +1,30 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, NotFound } from "../errors.ts"; + +// Input Schema +export const DeleteTeamMemberInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id: Schema.String.pipe(T.PathParam()), + id_or_username: Schema.String.pipe(T.PathParam()), +}).pipe( + T.Http({ method: "DELETE", path: "/team/{id}/members/{id_or_username}" }), +); +export type DeleteTeamMemberInput = typeof DeleteTeamMemberInput.Type; + +// Output Schema +export const DeleteTeamMemberOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type DeleteTeamMemberOutput = typeof DeleteTeamMemberOutput.Type; + +// The operation +/** + * Remove a member from a team + * + * @param id - The ID of the team + * @param id_or_username - The ID or username of the user + */ +export const deleteTeamMember = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: DeleteTeamMemberInput, + outputSchema: DeleteTeamMemberOutput, + errors: [BadRequest, NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/deleteThreadMessage.ts b/packages/modrinth/src/operations/deleteThreadMessage.ts new file mode 100644 index 000000000..f66be13e2 --- /dev/null +++ b/packages/modrinth/src/operations/deleteThreadMessage.ts @@ -0,0 +1,28 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, NotFound } from "../errors.ts"; + +// Input Schema +export const DeleteThreadMessageInput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id: Schema.String.pipe(T.PathParam()), + }).pipe(T.Http({ method: "DELETE", path: "/message/{id}" })); +export type DeleteThreadMessageInput = typeof DeleteThreadMessageInput.Type; + +// Output Schema +export const DeleteThreadMessageOutput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type DeleteThreadMessageOutput = typeof DeleteThreadMessageOutput.Type; + +// The operation +/** + * Delete a thread message + * + * @param id - The ID of the message + */ +export const deleteThreadMessage = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: DeleteThreadMessageInput, + outputSchema: DeleteThreadMessageOutput, + errors: [BadRequest, NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/deleteUserIcon.ts b/packages/modrinth/src/operations/deleteUserIcon.ts new file mode 100644 index 000000000..31666391c --- /dev/null +++ b/packages/modrinth/src/operations/deleteUserIcon.ts @@ -0,0 +1,26 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, NotFound } from "../errors.ts"; + +// Input Schema +export const DeleteUserIconInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id_or_username: Schema.String.pipe(T.PathParam()), +}).pipe(T.Http({ method: "DELETE", path: "/user/{id_or_username}/icon" })); +export type DeleteUserIconInput = typeof DeleteUserIconInput.Type; + +// Output Schema +export const DeleteUserIconOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type DeleteUserIconOutput = typeof DeleteUserIconOutput.Type; + +// The operation +/** + * Remove user's avatar + * + * @param id_or_username - The ID or username of the user + */ +export const deleteUserIcon = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: DeleteUserIconInput, + outputSchema: DeleteUserIconOutput, + errors: [BadRequest, NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/deleteVersion.ts b/packages/modrinth/src/operations/deleteVersion.ts new file mode 100644 index 000000000..25548222a --- /dev/null +++ b/packages/modrinth/src/operations/deleteVersion.ts @@ -0,0 +1,26 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, NotFound } from "../errors.ts"; + +// Input Schema +export const DeleteVersionInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id: Schema.String.pipe(T.PathParam()), +}).pipe(T.Http({ method: "DELETE", path: "/version/{id}" })); +export type DeleteVersionInput = typeof DeleteVersionInput.Type; + +// Output Schema +export const DeleteVersionOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type DeleteVersionOutput = typeof DeleteVersionOutput.Type; + +// The operation +/** + * Delete a version + * + * @param id - The ID of the version + */ +export const deleteVersion = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: DeleteVersionInput, + outputSchema: DeleteVersionOutput, + errors: [BadRequest, NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/donationPlatformList.ts b/packages/modrinth/src/operations/donationPlatformList.ts new file mode 100644 index 000000000..0d02edc63 --- /dev/null +++ b/packages/modrinth/src/operations/donationPlatformList.ts @@ -0,0 +1,33 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; + +// Input Schema +export const DonationPlatformListInput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({}).pipe( + T.Http({ method: "GET", path: "/tag/donation_platform" }), + ); +export type DonationPlatformListInput = typeof DonationPlatformListInput.Type; + +// Output Schema +export const DonationPlatformListOutput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Array( + Schema.Struct({ + short: Schema.String, + name: Schema.String, + }), + ); +export type DonationPlatformListOutput = typeof DonationPlatformListOutput.Type; + +// The operation +/** + * Get a list of donation platforms + * + * Gets an array of donation platforms and information about them + */ +export const donationPlatformList = /*@__PURE__*/ /*#__PURE__*/ API.make( + () => ({ + inputSchema: DonationPlatformListInput, + outputSchema: DonationPlatformListOutput, + }), +); diff --git a/packages/modrinth/src/operations/followProject.ts b/packages/modrinth/src/operations/followProject.ts new file mode 100644 index 000000000..f1d4d0383 --- /dev/null +++ b/packages/modrinth/src/operations/followProject.ts @@ -0,0 +1,26 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest } from "../errors.ts"; + +// Input Schema +export const FollowProjectInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id_or_slug: Schema.String.pipe(T.PathParam()), +}).pipe(T.Http({ method: "POST", path: "/project/{id_or_slug}/follow" })); +export type FollowProjectInput = typeof FollowProjectInput.Type; + +// Output Schema +export const FollowProjectOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type FollowProjectOutput = typeof FollowProjectOutput.Type; + +// The operation +/** + * Follow a project + * + * @param id_or_slug - The ID or slug of the project + */ +export const followProject = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: FollowProjectInput, + outputSchema: FollowProjectOutput, + errors: [BadRequest] as const, +})); diff --git a/packages/modrinth/src/operations/forgeUpdates.ts b/packages/modrinth/src/operations/forgeUpdates.ts new file mode 100644 index 000000000..c28c90460 --- /dev/null +++ b/packages/modrinth/src/operations/forgeUpdates.ts @@ -0,0 +1,59 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest } from "../errors.ts"; + +// Input Schema +export const ForgeUpdatesInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id_or_slug: Schema.String.pipe(T.PathParam()), + neoforge: Schema.optional(Schema.Literals(["only", "include"])), +}).pipe( + T.Http({ method: "GET", path: "/updates/{id_or_slug}/forge_updates.json" }), +); +export type ForgeUpdatesInput = typeof ForgeUpdatesInput.Type; + +// Output Schema +export const ForgeUpdatesOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + homepage: Schema.optional(Schema.String), + promos: Schema.optional( + Schema.Struct({ + "{version}-recommended": Schema.optional(Schema.String), + "{version}-latest": Schema.optional(Schema.String), + }), + ), +}); +export type ForgeUpdatesOutput = typeof ForgeUpdatesOutput.Type; + +// The operation +/** + * Forge Updates JSON file + * + * If you're a Forge mod developer, your Modrinth mods have an automatically generated `updates.json` using the + * [Forge Update Checker](https://docs.minecraftforge.net/en/latest/misc/updatechecker/). + * The only setup is to insert the URL into the `[[mods]]` section of your `mods.toml` file as such: + * ```toml + * [[mods]] + * # the other stuff here - ID, version, display name, etc. + * updateJSONURL = "https://api.modrinth.com/updates/{slug|ID}/forge_updates.json" + * ``` + * Replace `{slug|id}` with the slug or ID of your project. + * Modrinth will handle the rest! When you update your mod, Forge will notify your users that their copy of your mod is out of date. + * Make sure that the version format you use for your Modrinth releases is the same as the version format you use in your `mods.toml`. + * If you use a format such as `1.2.3-forge` or `1.2.3+1.19` with your Modrinth releases but your `mods.toml` only has `1.2.3`, + * the update checker may not function properly. + * If you're using NeoForge, NeoForge versions will, by default, not appear in the default URL. + * You will need to add `?neoforge=only` to show your NeoForge-only versions, or `?neoforge=include` for both. + * ```toml + * [[mods]] + * # the other stuff here - ID, version, display name, etc. + * updateJSONURL = "https://api.modrinth.com/updates/{slug|ID}/forge_updates.json?neoforge=only" + * ``` + * + * @param id_or_slug - The ID or slug of the project + * @param neoforge - Whether to include NeoForge versions. Can be `only` (NeoForge-only versions), `include` (both Forge and NeoForge versions), or omitted (Forge-only versions). + */ +export const forgeUpdates = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: ForgeUpdatesInput, + outputSchema: ForgeUpdatesOutput, + errors: [BadRequest] as const, +})); diff --git a/packages/modrinth/src/operations/getDependencies.ts b/packages/modrinth/src/operations/getDependencies.ts new file mode 100644 index 000000000..31659eeae --- /dev/null +++ b/packages/modrinth/src/operations/getDependencies.ts @@ -0,0 +1,135 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { NotFound } from "../errors.ts"; + +// Input Schema +export const GetDependenciesInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id_or_slug: Schema.String.pipe(T.PathParam()), +}).pipe(T.Http({ method: "GET", path: "/project/{id_or_slug}/dependencies" })); +export type GetDependenciesInput = typeof GetDependenciesInput.Type; + +// Output Schema +export const GetDependenciesOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + projects: Schema.optional( + Schema.Array( + Schema.Struct({ + id: Schema.String, + team: Schema.String, + body_url: Schema.optional(Schema.NullOr(Schema.String)), + moderator_message: Schema.optional( + Schema.NullOr( + Schema.Struct({ + message: Schema.optional(Schema.String), + body: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + published: Schema.String, + updated: Schema.String, + approved: Schema.optional(Schema.NullOr(Schema.String)), + queued: Schema.optional(Schema.NullOr(Schema.String)), + followers: Schema.Number, + license: Schema.optional( + Schema.Struct({ + id: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), + url: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + versions: Schema.optional(Schema.Array(Schema.String)), + game_versions: Schema.optional(Schema.Array(Schema.String)), + loaders: Schema.optional(Schema.Array(Schema.String)), + gallery: Schema.optional( + Schema.Array( + Schema.NullOr( + Schema.Struct({ + url: Schema.String, + featured: Schema.Boolean, + title: Schema.optional(Schema.NullOr(Schema.String)), + description: Schema.optional(Schema.NullOr(Schema.String)), + created: Schema.String, + ordering: Schema.optional(Schema.Number), + }), + ), + ), + ), + }), + ), + ), + versions: Schema.optional( + Schema.Array( + Schema.Struct({ + name: Schema.String, + version_number: Schema.String, + changelog: Schema.optional(Schema.NullOr(Schema.String)), + dependencies: Schema.optional( + Schema.Array( + Schema.Struct({ + version_id: Schema.optional(Schema.NullOr(Schema.String)), + project_id: Schema.optional(Schema.NullOr(Schema.String)), + file_name: Schema.optional(Schema.NullOr(Schema.String)), + dependency_type: Schema.Literals([ + "required", + "optional", + "incompatible", + "embedded", + ]), + }), + ), + ), + game_versions: Schema.Array(Schema.String), + version_type: Schema.Literals(["release", "beta", "alpha"]), + loaders: Schema.Array(Schema.String), + featured: Schema.Boolean, + status: Schema.optional( + Schema.Literals([ + "listed", + "archived", + "draft", + "unlisted", + "scheduled", + "unknown", + ]), + ), + requested_status: Schema.optional( + Schema.NullOr( + Schema.Literals(["listed", "archived", "draft", "unlisted"]), + ), + ), + id: Schema.String, + project_id: Schema.String, + author_id: Schema.String, + date_published: Schema.String, + downloads: Schema.Number, + changelog_url: Schema.optional(Schema.NullOr(Schema.String)), + files: Schema.Array( + Schema.Struct({ + hashes: Schema.Struct({ + sha512: Schema.optional(Schema.String), + sha1: Schema.optional(Schema.String), + }), + url: Schema.String, + filename: Schema.String, + primary: Schema.Boolean, + size: Schema.Number, + file_type: Schema.optional(Schema.Struct({})), + }), + ), + }), + ), + ), +}); +export type GetDependenciesOutput = typeof GetDependenciesOutput.Type; + +// The operation +/** + * Get all of a project's dependencies + * + * @param id_or_slug - The ID or slug of the project + */ +export const getDependencies = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: GetDependenciesInput, + outputSchema: GetDependenciesOutput, + errors: [NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/getFollowedProjects.ts b/packages/modrinth/src/operations/getFollowedProjects.ts new file mode 100644 index 000000000..c34a3022b --- /dev/null +++ b/packages/modrinth/src/operations/getFollowedProjects.ts @@ -0,0 +1,71 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { NotFound } from "../errors.ts"; + +// Input Schema +export const GetFollowedProjectsInput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id_or_username: Schema.String.pipe(T.PathParam()), + }).pipe(T.Http({ method: "GET", path: "/user/{id_or_username}/follows" })); +export type GetFollowedProjectsInput = typeof GetFollowedProjectsInput.Type; + +// Output Schema +export const GetFollowedProjectsOutput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Array( + Schema.Struct({ + id: Schema.String, + team: Schema.String, + body_url: Schema.optional(Schema.NullOr(Schema.String)), + moderator_message: Schema.optional( + Schema.NullOr( + Schema.Struct({ + message: Schema.optional(Schema.String), + body: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + published: Schema.String, + updated: Schema.String, + approved: Schema.optional(Schema.NullOr(Schema.String)), + queued: Schema.optional(Schema.NullOr(Schema.String)), + followers: Schema.Number, + license: Schema.optional( + Schema.Struct({ + id: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), + url: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + versions: Schema.optional(Schema.Array(Schema.String)), + game_versions: Schema.optional(Schema.Array(Schema.String)), + loaders: Schema.optional(Schema.Array(Schema.String)), + gallery: Schema.optional( + Schema.Array( + Schema.NullOr( + Schema.Struct({ + url: Schema.String, + featured: Schema.Boolean, + title: Schema.optional(Schema.NullOr(Schema.String)), + description: Schema.optional(Schema.NullOr(Schema.String)), + created: Schema.String, + ordering: Schema.optional(Schema.Number), + }), + ), + ), + ), + }), + ); +export type GetFollowedProjectsOutput = typeof GetFollowedProjectsOutput.Type; + +// The operation +/** + * Get user's followed projects + * + * @param id_or_username - The ID or username of the user + */ +export const getFollowedProjects = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: GetFollowedProjectsInput, + outputSchema: GetFollowedProjectsOutput, + errors: [NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/getLatestVersionFromHash.ts b/packages/modrinth/src/operations/getLatestVersionFromHash.ts new file mode 100644 index 000000000..9a30f4ef2 --- /dev/null +++ b/packages/modrinth/src/operations/getLatestVersionFromHash.ts @@ -0,0 +1,93 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, NotFound } from "../errors.ts"; + +// Input Schema +export const GetLatestVersionFromHashInput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + hash: Schema.String.pipe(T.PathParam()), + algorithm: Schema.Literals(["sha1", "sha512"]), + loaders: Schema.Array(Schema.String), + game_versions: Schema.Array(Schema.String), + }).pipe(T.Http({ method: "POST", path: "/version_file/{hash}/update" })); +export type GetLatestVersionFromHashInput = + typeof GetLatestVersionFromHashInput.Type; + +// Output Schema +export const GetLatestVersionFromHashOutput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + name: Schema.String, + version_number: Schema.String, + changelog: Schema.optional(Schema.NullOr(Schema.String)), + dependencies: Schema.optional( + Schema.Array( + Schema.Struct({ + version_id: Schema.optional(Schema.NullOr(Schema.String)), + project_id: Schema.optional(Schema.NullOr(Schema.String)), + file_name: Schema.optional(Schema.NullOr(Schema.String)), + dependency_type: Schema.Literals([ + "required", + "optional", + "incompatible", + "embedded", + ]), + }), + ), + ), + game_versions: Schema.Array(Schema.String), + version_type: Schema.Literals(["release", "beta", "alpha"]), + loaders: Schema.Array(Schema.String), + featured: Schema.Boolean, + status: Schema.optional( + Schema.Literals([ + "listed", + "archived", + "draft", + "unlisted", + "scheduled", + "unknown", + ]), + ), + requested_status: Schema.optional( + Schema.NullOr( + Schema.Literals(["listed", "archived", "draft", "unlisted"]), + ), + ), + id: Schema.String, + project_id: Schema.String, + author_id: Schema.String, + date_published: Schema.String, + downloads: Schema.Number, + changelog_url: Schema.optional(Schema.NullOr(Schema.String)), + files: Schema.Array( + Schema.Struct({ + hashes: Schema.Struct({ + sha512: Schema.optional(Schema.String), + sha1: Schema.optional(Schema.String), + }), + url: Schema.String, + filename: Schema.String, + primary: Schema.Boolean, + size: Schema.Number, + file_type: Schema.optional(Schema.Struct({})), + }), + ), + }); +export type GetLatestVersionFromHashOutput = + typeof GetLatestVersionFromHashOutput.Type; + +// The operation +/** + * Latest version of a project from a hash, loader(s), and game version(s) + * + * @param hash - The hash of the file, considering its byte content, and encoded in hexadecimal + * @param algorithm - The algorithm of the hash + */ +export const getLatestVersionFromHash = /*@__PURE__*/ /*#__PURE__*/ API.make( + () => ({ + inputSchema: GetLatestVersionFromHashInput, + outputSchema: GetLatestVersionFromHashOutput, + errors: [BadRequest, NotFound] as const, + }), +); diff --git a/packages/modrinth/src/operations/getLatestVersionsFromHashes.ts b/packages/modrinth/src/operations/getLatestVersionsFromHashes.ts new file mode 100644 index 000000000..14eeeeba6 --- /dev/null +++ b/packages/modrinth/src/operations/getLatestVersionsFromHashes.ts @@ -0,0 +1,95 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest } from "../errors.ts"; + +// Input Schema +export const GetLatestVersionsFromHashesInput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + hashes: Schema.Array(Schema.String), + algorithm: Schema.Literals(["sha1", "sha512"]), + loaders: Schema.Array(Schema.String), + game_versions: Schema.Array(Schema.String), + }).pipe(T.Http({ method: "POST", path: "/version_files/update" })); +export type GetLatestVersionsFromHashesInput = + typeof GetLatestVersionsFromHashesInput.Type; + +// Output Schema +export const GetLatestVersionsFromHashesOutput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Record( + Schema.String, + Schema.Struct({ + name: Schema.String, + version_number: Schema.String, + changelog: Schema.optional(Schema.NullOr(Schema.String)), + dependencies: Schema.optional( + Schema.Array( + Schema.Struct({ + version_id: Schema.optional(Schema.NullOr(Schema.String)), + project_id: Schema.optional(Schema.NullOr(Schema.String)), + file_name: Schema.optional(Schema.NullOr(Schema.String)), + dependency_type: Schema.Literals([ + "required", + "optional", + "incompatible", + "embedded", + ]), + }), + ), + ), + game_versions: Schema.Array(Schema.String), + version_type: Schema.Literals(["release", "beta", "alpha"]), + loaders: Schema.Array(Schema.String), + featured: Schema.Boolean, + status: Schema.optional( + Schema.Literals([ + "listed", + "archived", + "draft", + "unlisted", + "scheduled", + "unknown", + ]), + ), + requested_status: Schema.optional( + Schema.NullOr( + Schema.Literals(["listed", "archived", "draft", "unlisted"]), + ), + ), + id: Schema.String, + project_id: Schema.String, + author_id: Schema.String, + date_published: Schema.String, + downloads: Schema.Number, + changelog_url: Schema.optional(Schema.NullOr(Schema.String)), + files: Schema.Array( + Schema.Struct({ + hashes: Schema.Struct({ + sha512: Schema.optional(Schema.String), + sha1: Schema.optional(Schema.String), + }), + url: Schema.String, + filename: Schema.String, + primary: Schema.Boolean, + size: Schema.Number, + file_type: Schema.optional(Schema.Struct({})), + }), + ), + }), + ); +export type GetLatestVersionsFromHashesOutput = + typeof GetLatestVersionsFromHashesOutput.Type; + +// The operation +/** + * Latest versions of multiple project from hashes, loader(s), and game version(s) + * + * This is the same as [`/version_file/{hash}/update`](#operation/getLatestVersionFromHash) except it accepts multiple hashes. + */ +export const getLatestVersionsFromHashes = /*@__PURE__*/ /*#__PURE__*/ API.make( + () => ({ + inputSchema: GetLatestVersionsFromHashesInput, + outputSchema: GetLatestVersionsFromHashesOutput, + errors: [BadRequest] as const, + }), +); diff --git a/packages/modrinth/src/operations/getNotification.ts b/packages/modrinth/src/operations/getNotification.ts new file mode 100644 index 000000000..7eb934788 --- /dev/null +++ b/packages/modrinth/src/operations/getNotification.ts @@ -0,0 +1,50 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, NotFound } from "../errors.ts"; + +// Input Schema +export const GetNotificationInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id: Schema.String.pipe(T.PathParam()), +}).pipe(T.Http({ method: "GET", path: "/notification/{id}" })); +export type GetNotificationInput = typeof GetNotificationInput.Type; + +// Output Schema +export const GetNotificationOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id: Schema.String, + user_id: Schema.String, + type: Schema.optional( + Schema.NullOr( + Schema.Literals([ + "project_update", + "team_invite", + "status_change", + "moderator_message", + ]), + ), + ), + title: Schema.String, + text: Schema.String, + link: Schema.String, + read: Schema.Boolean, + created: Schema.String, + actions: Schema.Array( + Schema.Struct({ + title: Schema.optional(Schema.String), + action_route: Schema.optional(Schema.Array(Schema.String)), + }), + ), +}); +export type GetNotificationOutput = typeof GetNotificationOutput.Type; + +// The operation +/** + * Get notification from ID + * + * @param id - The ID of the notification + */ +export const getNotification = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: GetNotificationInput, + outputSchema: GetNotificationOutput, + errors: [BadRequest, NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/getNotifications.ts b/packages/modrinth/src/operations/getNotifications.ts new file mode 100644 index 000000000..3180e7131 --- /dev/null +++ b/packages/modrinth/src/operations/getNotifications.ts @@ -0,0 +1,52 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, NotFound } from "../errors.ts"; + +// Input Schema +export const GetNotificationsInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + ids: Schema.String, +}).pipe(T.Http({ method: "GET", path: "/notifications" })); +export type GetNotificationsInput = typeof GetNotificationsInput.Type; + +// Output Schema +export const GetNotificationsOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Array( + Schema.Struct({ + id: Schema.String, + user_id: Schema.String, + type: Schema.optional( + Schema.NullOr( + Schema.Literals([ + "project_update", + "team_invite", + "status_change", + "moderator_message", + ]), + ), + ), + title: Schema.String, + text: Schema.String, + link: Schema.String, + read: Schema.Boolean, + created: Schema.String, + actions: Schema.Array( + Schema.Struct({ + title: Schema.optional(Schema.String), + action_route: Schema.optional(Schema.Array(Schema.String)), + }), + ), + }), +); +export type GetNotificationsOutput = typeof GetNotificationsOutput.Type; + +// The operation +/** + * Get multiple notifications + * + * @param ids - The IDs of the notifications + */ +export const getNotifications = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: GetNotificationsInput, + outputSchema: GetNotificationsOutput, + errors: [BadRequest, NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/getOpenReports.ts b/packages/modrinth/src/operations/getOpenReports.ts new file mode 100644 index 000000000..c9a368ecd --- /dev/null +++ b/packages/modrinth/src/operations/getOpenReports.ts @@ -0,0 +1,36 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { NotFound } from "../errors.ts"; + +// Input Schema +export const GetOpenReportsInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + count: Schema.optional(Schema.Number), +}).pipe(T.Http({ method: "GET", path: "/report" })); +export type GetOpenReportsInput = typeof GetOpenReportsInput.Type; + +// Output Schema +export const GetOpenReportsOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Array( + Schema.Struct({ + report_type: Schema.String, + item_id: Schema.String, + item_type: Schema.Literals(["project", "user", "version"]), + body: Schema.String, + id: Schema.optional(Schema.String), + reporter: Schema.String, + created: Schema.String, + closed: Schema.Boolean, + thread_id: Schema.String, + }), +); +export type GetOpenReportsOutput = typeof GetOpenReportsOutput.Type; + +// The operation +/** + * Get your open reports + */ +export const getOpenReports = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: GetOpenReportsInput, + outputSchema: GetOpenReportsOutput, + errors: [NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/getPayoutHistory.ts b/packages/modrinth/src/operations/getPayoutHistory.ts new file mode 100644 index 000000000..685ba950b --- /dev/null +++ b/packages/modrinth/src/operations/getPayoutHistory.ts @@ -0,0 +1,40 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { NotFound } from "../errors.ts"; + +// Input Schema +export const GetPayoutHistoryInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id_or_username: Schema.String.pipe(T.PathParam()), +}).pipe(T.Http({ method: "GET", path: "/user/{id_or_username}/payouts" })); +export type GetPayoutHistoryInput = typeof GetPayoutHistoryInput.Type; + +// Output Schema +export const GetPayoutHistoryOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct( + { + all_time: Schema.optional(Schema.String), + last_month: Schema.optional(Schema.String), + payouts: Schema.optional( + Schema.Array( + Schema.Struct({ + created: Schema.optional(Schema.String), + amount: Schema.optional(Schema.Number), + status: Schema.optional(Schema.String), + }), + ), + ), + }, +); +export type GetPayoutHistoryOutput = typeof GetPayoutHistoryOutput.Type; + +// The operation +/** + * Get user's payout history + * + * @param id_or_username - The ID or username of the user + */ +export const getPayoutHistory = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: GetPayoutHistoryInput, + outputSchema: GetPayoutHistoryOutput, + errors: [NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/getProject.ts b/packages/modrinth/src/operations/getProject.ts new file mode 100644 index 000000000..da5469333 --- /dev/null +++ b/packages/modrinth/src/operations/getProject.ts @@ -0,0 +1,67 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { NotFound } from "../errors.ts"; + +// Input Schema +export const GetProjectInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id_or_slug: Schema.String.pipe(T.PathParam()), +}).pipe(T.Http({ method: "GET", path: "/project/{id_or_slug}" })); +export type GetProjectInput = typeof GetProjectInput.Type; + +// Output Schema +export const GetProjectOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id: Schema.String, + team: Schema.String, + body_url: Schema.optional(Schema.NullOr(Schema.String)), + moderator_message: Schema.optional( + Schema.NullOr( + Schema.Struct({ + message: Schema.optional(Schema.String), + body: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + published: Schema.String, + updated: Schema.String, + approved: Schema.optional(Schema.NullOr(Schema.String)), + queued: Schema.optional(Schema.NullOr(Schema.String)), + followers: Schema.Number, + license: Schema.optional( + Schema.Struct({ + id: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), + url: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + versions: Schema.optional(Schema.Array(Schema.String)), + game_versions: Schema.optional(Schema.Array(Schema.String)), + loaders: Schema.optional(Schema.Array(Schema.String)), + gallery: Schema.optional( + Schema.Array( + Schema.NullOr( + Schema.Struct({ + url: Schema.String, + featured: Schema.Boolean, + title: Schema.optional(Schema.NullOr(Schema.String)), + description: Schema.optional(Schema.NullOr(Schema.String)), + created: Schema.String, + ordering: Schema.optional(Schema.Number), + }), + ), + ), + ), +}); +export type GetProjectOutput = typeof GetProjectOutput.Type; + +// The operation +/** + * Get a project + * + * @param id_or_slug - The ID or slug of the project + */ +export const getProject = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: GetProjectInput, + outputSchema: GetProjectOutput, + errors: [NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/getProjectTeamMembers.ts b/packages/modrinth/src/operations/getProjectTeamMembers.ts new file mode 100644 index 000000000..362466396 --- /dev/null +++ b/packages/modrinth/src/operations/getProjectTeamMembers.ts @@ -0,0 +1,72 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { NotFound } from "../errors.ts"; + +// Input Schema +export const GetProjectTeamMembersInput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id_or_slug: Schema.String.pipe(T.PathParam()), + }).pipe(T.Http({ method: "GET", path: "/project/{id_or_slug}/members" })); +export type GetProjectTeamMembersInput = typeof GetProjectTeamMembersInput.Type; + +// Output Schema +export const GetProjectTeamMembersOutput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Array( + Schema.Struct({ + team_id: Schema.String, + user: Schema.Struct({ + username: Schema.String, + name: Schema.optional(Schema.NullOr(Schema.String)), + email: Schema.optional(Schema.NullOr(Schema.String)), + bio: Schema.optional(Schema.String), + payout_data: Schema.optional( + Schema.NullOr( + Schema.Struct({ + balance: Schema.optional(Schema.Number), + payout_wallet: Schema.optional( + Schema.Literals(["paypal", "venmo"]), + ), + payout_wallet_type: Schema.optional( + Schema.Literals(["email", "phone", "user_handle"]), + ), + payout_address: Schema.optional(Schema.String), + }), + ), + ), + id: Schema.String, + avatar_url: Schema.String, + created: Schema.String, + role: Schema.Literals(["admin", "moderator", "developer"]), + badges: Schema.optional(Schema.Number), + auth_providers: Schema.optional( + Schema.NullOr(Schema.Array(Schema.String)), + ), + email_verified: Schema.optional(Schema.NullOr(Schema.Boolean)), + has_password: Schema.optional(Schema.NullOr(Schema.Boolean)), + has_totp: Schema.optional(Schema.NullOr(Schema.Boolean)), + github_id: Schema.optional(Schema.NullOr(Schema.Number)), + }), + role: Schema.String, + permissions: Schema.optional(Schema.Number), + accepted: Schema.Boolean, + payouts_split: Schema.optional(Schema.Number), + ordering: Schema.optional(Schema.Number), + }), + ); +export type GetProjectTeamMembersOutput = + typeof GetProjectTeamMembersOutput.Type; + +// The operation +/** + * Get a project's team members + * + * @param id_or_slug - The ID or slug of the project + */ +export const getProjectTeamMembers = /*@__PURE__*/ /*#__PURE__*/ API.make( + () => ({ + inputSchema: GetProjectTeamMembersInput, + outputSchema: GetProjectTeamMembersOutput, + errors: [NotFound] as const, + }), +); diff --git a/packages/modrinth/src/operations/getProjectVersions.ts b/packages/modrinth/src/operations/getProjectVersions.ts new file mode 100644 index 000000000..fb153c902 --- /dev/null +++ b/packages/modrinth/src/operations/getProjectVersions.ts @@ -0,0 +1,95 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { NotFound } from "../errors.ts"; + +// Input Schema +export const GetProjectVersionsInput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id_or_slug: Schema.String.pipe(T.PathParam()), + loaders: Schema.optional(Schema.String), + game_versions: Schema.optional(Schema.String), + featured: Schema.optional(Schema.Boolean), + include_changelog: Schema.optional(Schema.Boolean), + }).pipe(T.Http({ method: "GET", path: "/project/{id_or_slug}/version" })); +export type GetProjectVersionsInput = typeof GetProjectVersionsInput.Type; + +// Output Schema +export const GetProjectVersionsOutput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Array( + Schema.Struct({ + name: Schema.String, + version_number: Schema.String, + changelog: Schema.optional(Schema.NullOr(Schema.String)), + dependencies: Schema.optional( + Schema.Array( + Schema.Struct({ + version_id: Schema.optional(Schema.NullOr(Schema.String)), + project_id: Schema.optional(Schema.NullOr(Schema.String)), + file_name: Schema.optional(Schema.NullOr(Schema.String)), + dependency_type: Schema.Literals([ + "required", + "optional", + "incompatible", + "embedded", + ]), + }), + ), + ), + game_versions: Schema.Array(Schema.String), + version_type: Schema.Literals(["release", "beta", "alpha"]), + loaders: Schema.Array(Schema.String), + featured: Schema.Boolean, + status: Schema.optional( + Schema.Literals([ + "listed", + "archived", + "draft", + "unlisted", + "scheduled", + "unknown", + ]), + ), + requested_status: Schema.optional( + Schema.NullOr( + Schema.Literals(["listed", "archived", "draft", "unlisted"]), + ), + ), + id: Schema.String, + project_id: Schema.String, + author_id: Schema.String, + date_published: Schema.String, + downloads: Schema.Number, + changelog_url: Schema.optional(Schema.NullOr(Schema.String)), + files: Schema.Array( + Schema.Struct({ + hashes: Schema.Struct({ + sha512: Schema.optional(Schema.String), + sha1: Schema.optional(Schema.String), + }), + url: Schema.String, + filename: Schema.String, + primary: Schema.Boolean, + size: Schema.Number, + file_type: Schema.optional(Schema.Struct({})), + }), + ), + }), + ); +export type GetProjectVersionsOutput = typeof GetProjectVersionsOutput.Type; + +// The operation +/** + * List project's versions + * + * @param id_or_slug - The ID or slug of the project + * @param loaders - The types of loaders to filter for + * @param game_versions - The game versions to filter for + * @param featured - Allows to filter for featured or non-featured versions only + * @param include_changelog - Allows you to toggle the inclusion of the changelog field in the response. It is highly recommended to use include_changelog=false in most cases unless you specifically need the changelog for all versions. + */ +export const getProjectVersions = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: GetProjectVersionsInput, + outputSchema: GetProjectVersionsOutput, + errors: [NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/getProjects.ts b/packages/modrinth/src/operations/getProjects.ts new file mode 100644 index 000000000..abb2dd8cc --- /dev/null +++ b/packages/modrinth/src/operations/getProjects.ts @@ -0,0 +1,67 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; + +// Input Schema +export const GetProjectsInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + ids: Schema.String, +}).pipe(T.Http({ method: "GET", path: "/projects" })); +export type GetProjectsInput = typeof GetProjectsInput.Type; + +// Output Schema +export const GetProjectsOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Array( + Schema.Struct({ + id: Schema.String, + team: Schema.String, + body_url: Schema.optional(Schema.NullOr(Schema.String)), + moderator_message: Schema.optional( + Schema.NullOr( + Schema.Struct({ + message: Schema.optional(Schema.String), + body: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + published: Schema.String, + updated: Schema.String, + approved: Schema.optional(Schema.NullOr(Schema.String)), + queued: Schema.optional(Schema.NullOr(Schema.String)), + followers: Schema.Number, + license: Schema.optional( + Schema.Struct({ + id: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), + url: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + versions: Schema.optional(Schema.Array(Schema.String)), + game_versions: Schema.optional(Schema.Array(Schema.String)), + loaders: Schema.optional(Schema.Array(Schema.String)), + gallery: Schema.optional( + Schema.Array( + Schema.NullOr( + Schema.Struct({ + url: Schema.String, + featured: Schema.Boolean, + title: Schema.optional(Schema.NullOr(Schema.String)), + description: Schema.optional(Schema.NullOr(Schema.String)), + created: Schema.String, + ordering: Schema.optional(Schema.Number), + }), + ), + ), + ), + }), +); +export type GetProjectsOutput = typeof GetProjectsOutput.Type; + +// The operation +/** + * Get multiple projects + * + * @param ids - The IDs and/or slugs of the projects + */ +export const getProjects = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: GetProjectsInput, + outputSchema: GetProjectsOutput, +})); diff --git a/packages/modrinth/src/operations/getReport.ts b/packages/modrinth/src/operations/getReport.ts new file mode 100644 index 000000000..ffd24c9ae --- /dev/null +++ b/packages/modrinth/src/operations/getReport.ts @@ -0,0 +1,36 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, NotFound } from "../errors.ts"; + +// Input Schema +export const GetReportInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id: Schema.String.pipe(T.PathParam()), +}).pipe(T.Http({ method: "GET", path: "/report/{id}" })); +export type GetReportInput = typeof GetReportInput.Type; + +// Output Schema +export const GetReportOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + report_type: Schema.String, + item_id: Schema.String, + item_type: Schema.Literals(["project", "user", "version"]), + body: Schema.String, + id: Schema.optional(Schema.String), + reporter: Schema.String, + created: Schema.String, + closed: Schema.Boolean, + thread_id: Schema.String, +}); +export type GetReportOutput = typeof GetReportOutput.Type; + +// The operation +/** + * Get report from ID + * + * @param id - The ID of the report + */ +export const getReport = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: GetReportInput, + outputSchema: GetReportOutput, + errors: [BadRequest, NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/getReports.ts b/packages/modrinth/src/operations/getReports.ts new file mode 100644 index 000000000..913c5a54a --- /dev/null +++ b/packages/modrinth/src/operations/getReports.ts @@ -0,0 +1,38 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, NotFound } from "../errors.ts"; + +// Input Schema +export const GetReportsInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + ids: Schema.String, +}).pipe(T.Http({ method: "GET", path: "/reports" })); +export type GetReportsInput = typeof GetReportsInput.Type; + +// Output Schema +export const GetReportsOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Array( + Schema.Struct({ + report_type: Schema.String, + item_id: Schema.String, + item_type: Schema.Literals(["project", "user", "version"]), + body: Schema.String, + id: Schema.optional(Schema.String), + reporter: Schema.String, + created: Schema.String, + closed: Schema.Boolean, + thread_id: Schema.String, + }), +); +export type GetReportsOutput = typeof GetReportsOutput.Type; + +// The operation +/** + * Get multiple reports + * + * @param ids - The IDs of the reports + */ +export const getReports = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: GetReportsInput, + outputSchema: GetReportsOutput, + errors: [BadRequest, NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/getTeamMembers.ts b/packages/modrinth/src/operations/getTeamMembers.ts new file mode 100644 index 000000000..7e4f4b69f --- /dev/null +++ b/packages/modrinth/src/operations/getTeamMembers.ts @@ -0,0 +1,67 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest } from "../errors.ts"; + +// Input Schema +export const GetTeamMembersInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id: Schema.String.pipe(T.PathParam()), +}).pipe(T.Http({ method: "GET", path: "/team/{id}/members" })); +export type GetTeamMembersInput = typeof GetTeamMembersInput.Type; + +// Output Schema +export const GetTeamMembersOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Array( + Schema.Struct({ + team_id: Schema.String, + user: Schema.Struct({ + username: Schema.String, + name: Schema.optional(Schema.NullOr(Schema.String)), + email: Schema.optional(Schema.NullOr(Schema.String)), + bio: Schema.optional(Schema.String), + payout_data: Schema.optional( + Schema.NullOr( + Schema.Struct({ + balance: Schema.optional(Schema.Number), + payout_wallet: Schema.optional( + Schema.Literals(["paypal", "venmo"]), + ), + payout_wallet_type: Schema.optional( + Schema.Literals(["email", "phone", "user_handle"]), + ), + payout_address: Schema.optional(Schema.String), + }), + ), + ), + id: Schema.String, + avatar_url: Schema.String, + created: Schema.String, + role: Schema.Literals(["admin", "moderator", "developer"]), + badges: Schema.optional(Schema.Number), + auth_providers: Schema.optional( + Schema.NullOr(Schema.Array(Schema.String)), + ), + email_verified: Schema.optional(Schema.NullOr(Schema.Boolean)), + has_password: Schema.optional(Schema.NullOr(Schema.Boolean)), + has_totp: Schema.optional(Schema.NullOr(Schema.Boolean)), + github_id: Schema.optional(Schema.NullOr(Schema.Number)), + }), + role: Schema.String, + permissions: Schema.optional(Schema.Number), + accepted: Schema.Boolean, + payouts_split: Schema.optional(Schema.Number), + ordering: Schema.optional(Schema.Number), + }), +); +export type GetTeamMembersOutput = typeof GetTeamMembersOutput.Type; + +// The operation +/** + * Get a team's members + * + * @param id - The ID of the team + */ +export const getTeamMembers = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: GetTeamMembersInput, + outputSchema: GetTeamMembersOutput, + errors: [BadRequest] as const, +})); diff --git a/packages/modrinth/src/operations/getTeams.ts b/packages/modrinth/src/operations/getTeams.ts new file mode 100644 index 000000000..109991a34 --- /dev/null +++ b/packages/modrinth/src/operations/getTeams.ts @@ -0,0 +1,69 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest } from "../errors.ts"; + +// Input Schema +export const GetTeamsInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + ids: Schema.String, +}).pipe(T.Http({ method: "GET", path: "/teams" })); +export type GetTeamsInput = typeof GetTeamsInput.Type; + +// Output Schema +export const GetTeamsOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Array( + Schema.Array( + Schema.Struct({ + team_id: Schema.String, + user: Schema.Struct({ + username: Schema.String, + name: Schema.optional(Schema.NullOr(Schema.String)), + email: Schema.optional(Schema.NullOr(Schema.String)), + bio: Schema.optional(Schema.String), + payout_data: Schema.optional( + Schema.NullOr( + Schema.Struct({ + balance: Schema.optional(Schema.Number), + payout_wallet: Schema.optional( + Schema.Literals(["paypal", "venmo"]), + ), + payout_wallet_type: Schema.optional( + Schema.Literals(["email", "phone", "user_handle"]), + ), + payout_address: Schema.optional(Schema.String), + }), + ), + ), + id: Schema.String, + avatar_url: Schema.String, + created: Schema.String, + role: Schema.Literals(["admin", "moderator", "developer"]), + badges: Schema.optional(Schema.Number), + auth_providers: Schema.optional( + Schema.NullOr(Schema.Array(Schema.String)), + ), + email_verified: Schema.optional(Schema.NullOr(Schema.Boolean)), + has_password: Schema.optional(Schema.NullOr(Schema.Boolean)), + has_totp: Schema.optional(Schema.NullOr(Schema.Boolean)), + github_id: Schema.optional(Schema.NullOr(Schema.Number)), + }), + role: Schema.String, + permissions: Schema.optional(Schema.Number), + accepted: Schema.Boolean, + payouts_split: Schema.optional(Schema.Number), + ordering: Schema.optional(Schema.Number), + }), + ), +); +export type GetTeamsOutput = typeof GetTeamsOutput.Type; + +// The operation +/** + * Get the members of multiple teams + * + * @param ids - The IDs of the teams + */ +export const getTeams = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: GetTeamsInput, + outputSchema: GetTeamsOutput, + errors: [BadRequest] as const, +})); diff --git a/packages/modrinth/src/operations/getThread.ts b/packages/modrinth/src/operations/getThread.ts new file mode 100644 index 000000000..2f31aefce --- /dev/null +++ b/packages/modrinth/src/operations/getThread.ts @@ -0,0 +1,111 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, NotFound } from "../errors.ts"; + +// Input Schema +export const GetThreadInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id: Schema.String.pipe(T.PathParam()), +}).pipe(T.Http({ method: "GET", path: "/thread/{id}" })); +export type GetThreadInput = typeof GetThreadInput.Type; + +// Output Schema +export const GetThreadOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id: Schema.String, + type: Schema.Literals(["project", "report", "direct_message"]), + project_id: Schema.optional(Schema.NullOr(Schema.String)), + report_id: Schema.optional(Schema.NullOr(Schema.String)), + messages: Schema.Array( + Schema.Struct({ + id: Schema.String, + author_id: Schema.optional(Schema.NullOr(Schema.String)), + body: Schema.Struct({ + type: Schema.Literals([ + "status_change", + "text", + "thread_closure", + "deleted", + ]), + body: Schema.optional(Schema.String), + private: Schema.optional(Schema.Boolean), + replying_to: Schema.optional(Schema.NullOr(Schema.String)), + old_status: Schema.optional( + Schema.Literals([ + "approved", + "archived", + "rejected", + "draft", + "unlisted", + "processing", + "withheld", + "scheduled", + "private", + "unknown", + ]), + ), + new_status: Schema.optional( + Schema.Literals([ + "approved", + "archived", + "rejected", + "draft", + "unlisted", + "processing", + "withheld", + "scheduled", + "private", + "unknown", + ]), + ), + }), + created: Schema.String, + }), + ), + members: Schema.Array( + Schema.Struct({ + username: Schema.String, + name: Schema.optional(Schema.NullOr(Schema.String)), + email: Schema.optional(Schema.NullOr(Schema.String)), + bio: Schema.optional(Schema.String), + payout_data: Schema.optional( + Schema.NullOr( + Schema.Struct({ + balance: Schema.optional(Schema.Number), + payout_wallet: Schema.optional( + Schema.Literals(["paypal", "venmo"]), + ), + payout_wallet_type: Schema.optional( + Schema.Literals(["email", "phone", "user_handle"]), + ), + payout_address: Schema.optional(Schema.String), + }), + ), + ), + id: Schema.String, + avatar_url: Schema.String, + created: Schema.String, + role: Schema.Literals(["admin", "moderator", "developer"]), + badges: Schema.optional(Schema.Number), + auth_providers: Schema.optional( + Schema.NullOr(Schema.Array(Schema.String)), + ), + email_verified: Schema.optional(Schema.NullOr(Schema.Boolean)), + has_password: Schema.optional(Schema.NullOr(Schema.Boolean)), + has_totp: Schema.optional(Schema.NullOr(Schema.Boolean)), + github_id: Schema.optional(Schema.NullOr(Schema.Number)), + }), + ), +}); +export type GetThreadOutput = typeof GetThreadOutput.Type; + +// The operation +/** + * Get a thread + * + * @param id - The ID of the thread + */ +export const getThread = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: GetThreadInput, + outputSchema: GetThreadOutput, + errors: [BadRequest, NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/getThreads.ts b/packages/modrinth/src/operations/getThreads.ts new file mode 100644 index 000000000..e7bd0c4d1 --- /dev/null +++ b/packages/modrinth/src/operations/getThreads.ts @@ -0,0 +1,113 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, NotFound } from "../errors.ts"; + +// Input Schema +export const GetThreadsInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + ids: Schema.String, +}).pipe(T.Http({ method: "GET", path: "/threads" })); +export type GetThreadsInput = typeof GetThreadsInput.Type; + +// Output Schema +export const GetThreadsOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Array( + Schema.Struct({ + id: Schema.String, + type: Schema.Literals(["project", "report", "direct_message"]), + project_id: Schema.optional(Schema.NullOr(Schema.String)), + report_id: Schema.optional(Schema.NullOr(Schema.String)), + messages: Schema.Array( + Schema.Struct({ + id: Schema.String, + author_id: Schema.optional(Schema.NullOr(Schema.String)), + body: Schema.Struct({ + type: Schema.Literals([ + "status_change", + "text", + "thread_closure", + "deleted", + ]), + body: Schema.optional(Schema.String), + private: Schema.optional(Schema.Boolean), + replying_to: Schema.optional(Schema.NullOr(Schema.String)), + old_status: Schema.optional( + Schema.Literals([ + "approved", + "archived", + "rejected", + "draft", + "unlisted", + "processing", + "withheld", + "scheduled", + "private", + "unknown", + ]), + ), + new_status: Schema.optional( + Schema.Literals([ + "approved", + "archived", + "rejected", + "draft", + "unlisted", + "processing", + "withheld", + "scheduled", + "private", + "unknown", + ]), + ), + }), + created: Schema.String, + }), + ), + members: Schema.Array( + Schema.Struct({ + username: Schema.String, + name: Schema.optional(Schema.NullOr(Schema.String)), + email: Schema.optional(Schema.NullOr(Schema.String)), + bio: Schema.optional(Schema.String), + payout_data: Schema.optional( + Schema.NullOr( + Schema.Struct({ + balance: Schema.optional(Schema.Number), + payout_wallet: Schema.optional( + Schema.Literals(["paypal", "venmo"]), + ), + payout_wallet_type: Schema.optional( + Schema.Literals(["email", "phone", "user_handle"]), + ), + payout_address: Schema.optional(Schema.String), + }), + ), + ), + id: Schema.String, + avatar_url: Schema.String, + created: Schema.String, + role: Schema.Literals(["admin", "moderator", "developer"]), + badges: Schema.optional(Schema.Number), + auth_providers: Schema.optional( + Schema.NullOr(Schema.Array(Schema.String)), + ), + email_verified: Schema.optional(Schema.NullOr(Schema.Boolean)), + has_password: Schema.optional(Schema.NullOr(Schema.Boolean)), + has_totp: Schema.optional(Schema.NullOr(Schema.Boolean)), + github_id: Schema.optional(Schema.NullOr(Schema.Number)), + }), + ), + }), +); +export type GetThreadsOutput = typeof GetThreadsOutput.Type; + +// The operation +/** + * Get multiple threads + * + * @param ids - The IDs of the threads + */ +export const getThreads = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: GetThreadsInput, + outputSchema: GetThreadsOutput, + errors: [BadRequest, NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/getUser.ts b/packages/modrinth/src/operations/getUser.ts new file mode 100644 index 000000000..4d4006cca --- /dev/null +++ b/packages/modrinth/src/operations/getUser.ts @@ -0,0 +1,53 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { NotFound } from "../errors.ts"; + +// Input Schema +export const GetUserInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id_or_username: Schema.String.pipe(T.PathParam()), +}).pipe(T.Http({ method: "GET", path: "/user/{id_or_username}" })); +export type GetUserInput = typeof GetUserInput.Type; + +// Output Schema +export const GetUserOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + username: Schema.String, + name: Schema.optional(Schema.NullOr(Schema.String)), + email: Schema.optional(Schema.NullOr(Schema.String)), + bio: Schema.optional(Schema.String), + payout_data: Schema.optional( + Schema.NullOr( + Schema.Struct({ + balance: Schema.optional(Schema.Number), + payout_wallet: Schema.optional(Schema.Literals(["paypal", "venmo"])), + payout_wallet_type: Schema.optional( + Schema.Literals(["email", "phone", "user_handle"]), + ), + payout_address: Schema.optional(Schema.String), + }), + ), + ), + id: Schema.String, + avatar_url: Schema.String, + created: Schema.String, + role: Schema.Literals(["admin", "moderator", "developer"]), + badges: Schema.optional(Schema.Number), + auth_providers: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))), + email_verified: Schema.optional(Schema.NullOr(Schema.Boolean)), + has_password: Schema.optional(Schema.NullOr(Schema.Boolean)), + has_totp: Schema.optional(Schema.NullOr(Schema.Boolean)), + github_id: Schema.optional(Schema.NullOr(Schema.Number)), +}); +export type GetUserOutput = typeof GetUserOutput.Type; + +// The operation +/** + * Get a user + * + * @param id_or_username - The ID or username of the user + */ +export const getUser = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: GetUserInput, + outputSchema: GetUserOutput, + errors: [NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/getUserFromAuth.ts b/packages/modrinth/src/operations/getUserFromAuth.ts new file mode 100644 index 000000000..c58d107a2 --- /dev/null +++ b/packages/modrinth/src/operations/getUserFromAuth.ts @@ -0,0 +1,49 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; + +// Input Schema +export const GetUserFromAuthInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct( + {}, +).pipe(T.Http({ method: "GET", path: "/user" })); +export type GetUserFromAuthInput = typeof GetUserFromAuthInput.Type; + +// Output Schema +export const GetUserFromAuthOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + username: Schema.String, + name: Schema.optional(Schema.NullOr(Schema.String)), + email: Schema.optional(Schema.NullOr(Schema.String)), + bio: Schema.optional(Schema.String), + payout_data: Schema.optional( + Schema.NullOr( + Schema.Struct({ + balance: Schema.optional(Schema.Number), + payout_wallet: Schema.optional(Schema.Literals(["paypal", "venmo"])), + payout_wallet_type: Schema.optional( + Schema.Literals(["email", "phone", "user_handle"]), + ), + payout_address: Schema.optional(Schema.String), + }), + ), + ), + id: Schema.String, + avatar_url: Schema.String, + created: Schema.String, + role: Schema.Literals(["admin", "moderator", "developer"]), + badges: Schema.optional(Schema.Number), + auth_providers: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))), + email_verified: Schema.optional(Schema.NullOr(Schema.Boolean)), + has_password: Schema.optional(Schema.NullOr(Schema.Boolean)), + has_totp: Schema.optional(Schema.NullOr(Schema.Boolean)), + github_id: Schema.optional(Schema.NullOr(Schema.Number)), +}); +export type GetUserFromAuthOutput = typeof GetUserFromAuthOutput.Type; + +// The operation +/** + * Get user from authorization header + */ +export const getUserFromAuth = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: GetUserFromAuthInput, + outputSchema: GetUserFromAuthOutput, +})); diff --git a/packages/modrinth/src/operations/getUserNotifications.ts b/packages/modrinth/src/operations/getUserNotifications.ts new file mode 100644 index 000000000..ff4968cdc --- /dev/null +++ b/packages/modrinth/src/operations/getUserNotifications.ts @@ -0,0 +1,58 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { NotFound } from "../errors.ts"; + +// Input Schema +export const GetUserNotificationsInput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id_or_username: Schema.String.pipe(T.PathParam()), + }).pipe( + T.Http({ method: "GET", path: "/user/{id_or_username}/notifications" }), + ); +export type GetUserNotificationsInput = typeof GetUserNotificationsInput.Type; + +// Output Schema +export const GetUserNotificationsOutput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Array( + Schema.Struct({ + id: Schema.String, + user_id: Schema.String, + type: Schema.optional( + Schema.NullOr( + Schema.Literals([ + "project_update", + "team_invite", + "status_change", + "moderator_message", + ]), + ), + ), + title: Schema.String, + text: Schema.String, + link: Schema.String, + read: Schema.Boolean, + created: Schema.String, + actions: Schema.Array( + Schema.Struct({ + title: Schema.optional(Schema.String), + action_route: Schema.optional(Schema.Array(Schema.String)), + }), + ), + }), + ); +export type GetUserNotificationsOutput = typeof GetUserNotificationsOutput.Type; + +// The operation +/** + * Get user's notifications + * + * @param id_or_username - The ID or username of the user + */ +export const getUserNotifications = /*@__PURE__*/ /*#__PURE__*/ API.make( + () => ({ + inputSchema: GetUserNotificationsInput, + outputSchema: GetUserNotificationsOutput, + errors: [NotFound] as const, + }), +); diff --git a/packages/modrinth/src/operations/getUserProjects.ts b/packages/modrinth/src/operations/getUserProjects.ts new file mode 100644 index 000000000..6416bda56 --- /dev/null +++ b/packages/modrinth/src/operations/getUserProjects.ts @@ -0,0 +1,69 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { NotFound } from "../errors.ts"; + +// Input Schema +export const GetUserProjectsInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id_or_username: Schema.String.pipe(T.PathParam()), +}).pipe(T.Http({ method: "GET", path: "/user/{id_or_username}/projects" })); +export type GetUserProjectsInput = typeof GetUserProjectsInput.Type; + +// Output Schema +export const GetUserProjectsOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Array( + Schema.Struct({ + id: Schema.String, + team: Schema.String, + body_url: Schema.optional(Schema.NullOr(Schema.String)), + moderator_message: Schema.optional( + Schema.NullOr( + Schema.Struct({ + message: Schema.optional(Schema.String), + body: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + published: Schema.String, + updated: Schema.String, + approved: Schema.optional(Schema.NullOr(Schema.String)), + queued: Schema.optional(Schema.NullOr(Schema.String)), + followers: Schema.Number, + license: Schema.optional( + Schema.Struct({ + id: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), + url: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + versions: Schema.optional(Schema.Array(Schema.String)), + game_versions: Schema.optional(Schema.Array(Schema.String)), + loaders: Schema.optional(Schema.Array(Schema.String)), + gallery: Schema.optional( + Schema.Array( + Schema.NullOr( + Schema.Struct({ + url: Schema.String, + featured: Schema.Boolean, + title: Schema.optional(Schema.NullOr(Schema.String)), + description: Schema.optional(Schema.NullOr(Schema.String)), + created: Schema.String, + ordering: Schema.optional(Schema.Number), + }), + ), + ), + ), + }), +); +export type GetUserProjectsOutput = typeof GetUserProjectsOutput.Type; + +// The operation +/** + * Get user's projects + * + * @param id_or_username - The ID or username of the user + */ +export const getUserProjects = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: GetUserProjectsInput, + outputSchema: GetUserProjectsOutput, + errors: [NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/getUsers.ts b/packages/modrinth/src/operations/getUsers.ts new file mode 100644 index 000000000..050219598 --- /dev/null +++ b/packages/modrinth/src/operations/getUsers.ts @@ -0,0 +1,55 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest } from "../errors.ts"; + +// Input Schema +export const GetUsersInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + ids: Schema.String, +}).pipe(T.Http({ method: "GET", path: "/users" })); +export type GetUsersInput = typeof GetUsersInput.Type; + +// Output Schema +export const GetUsersOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Array( + Schema.Struct({ + username: Schema.String, + name: Schema.optional(Schema.NullOr(Schema.String)), + email: Schema.optional(Schema.NullOr(Schema.String)), + bio: Schema.optional(Schema.String), + payout_data: Schema.optional( + Schema.NullOr( + Schema.Struct({ + balance: Schema.optional(Schema.Number), + payout_wallet: Schema.optional(Schema.Literals(["paypal", "venmo"])), + payout_wallet_type: Schema.optional( + Schema.Literals(["email", "phone", "user_handle"]), + ), + payout_address: Schema.optional(Schema.String), + }), + ), + ), + id: Schema.String, + avatar_url: Schema.String, + created: Schema.String, + role: Schema.Literals(["admin", "moderator", "developer"]), + badges: Schema.optional(Schema.Number), + auth_providers: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))), + email_verified: Schema.optional(Schema.NullOr(Schema.Boolean)), + has_password: Schema.optional(Schema.NullOr(Schema.Boolean)), + has_totp: Schema.optional(Schema.NullOr(Schema.Boolean)), + github_id: Schema.optional(Schema.NullOr(Schema.Number)), + }), +); +export type GetUsersOutput = typeof GetUsersOutput.Type; + +// The operation +/** + * Get multiple users + * + * @param ids - The IDs of the users + */ +export const getUsers = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: GetUsersInput, + outputSchema: GetUsersOutput, + errors: [BadRequest] as const, +})); diff --git a/packages/modrinth/src/operations/getVersion.ts b/packages/modrinth/src/operations/getVersion.ts new file mode 100644 index 000000000..7bdbb70ea --- /dev/null +++ b/packages/modrinth/src/operations/getVersion.ts @@ -0,0 +1,81 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, NotFound } from "../errors.ts"; + +// Input Schema +export const GetVersionInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id: Schema.String.pipe(T.PathParam()), +}).pipe(T.Http({ method: "GET", path: "/version/{id}" })); +export type GetVersionInput = typeof GetVersionInput.Type; + +// Output Schema +export const GetVersionOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + name: Schema.String, + version_number: Schema.String, + changelog: Schema.optional(Schema.NullOr(Schema.String)), + dependencies: Schema.optional( + Schema.Array( + Schema.Struct({ + version_id: Schema.optional(Schema.NullOr(Schema.String)), + project_id: Schema.optional(Schema.NullOr(Schema.String)), + file_name: Schema.optional(Schema.NullOr(Schema.String)), + dependency_type: Schema.Literals([ + "required", + "optional", + "incompatible", + "embedded", + ]), + }), + ), + ), + game_versions: Schema.Array(Schema.String), + version_type: Schema.Literals(["release", "beta", "alpha"]), + loaders: Schema.Array(Schema.String), + featured: Schema.Boolean, + status: Schema.optional( + Schema.Literals([ + "listed", + "archived", + "draft", + "unlisted", + "scheduled", + "unknown", + ]), + ), + requested_status: Schema.optional( + Schema.NullOr(Schema.Literals(["listed", "archived", "draft", "unlisted"])), + ), + id: Schema.String, + project_id: Schema.String, + author_id: Schema.String, + date_published: Schema.String, + downloads: Schema.Number, + changelog_url: Schema.optional(Schema.NullOr(Schema.String)), + files: Schema.Array( + Schema.Struct({ + hashes: Schema.Struct({ + sha512: Schema.optional(Schema.String), + sha1: Schema.optional(Schema.String), + }), + url: Schema.String, + filename: Schema.String, + primary: Schema.Boolean, + size: Schema.Number, + file_type: Schema.optional(Schema.Struct({})), + }), + ), +}); +export type GetVersionOutput = typeof GetVersionOutput.Type; + +// The operation +/** + * Get a version + * + * @param id - The ID of the version + */ +export const getVersion = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: GetVersionInput, + outputSchema: GetVersionOutput, + errors: [BadRequest, NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/getVersionFromIdOrNumber.ts b/packages/modrinth/src/operations/getVersionFromIdOrNumber.ts new file mode 100644 index 000000000..1fe356ed4 --- /dev/null +++ b/packages/modrinth/src/operations/getVersionFromIdOrNumber.ts @@ -0,0 +1,98 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { NotFound } from "../errors.ts"; + +// Input Schema +export const GetVersionFromIdOrNumberInput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id_or_slug: Schema.String.pipe(T.PathParam()), + id_or_number: Schema.String.pipe(T.PathParam()), + }).pipe( + T.Http({ + method: "GET", + path: "/project/{id_or_slug}/version/{id_or_number}", + }), + ); +export type GetVersionFromIdOrNumberInput = + typeof GetVersionFromIdOrNumberInput.Type; + +// Output Schema +export const GetVersionFromIdOrNumberOutput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + name: Schema.String, + version_number: Schema.String, + changelog: Schema.optional(Schema.NullOr(Schema.String)), + dependencies: Schema.optional( + Schema.Array( + Schema.Struct({ + version_id: Schema.optional(Schema.NullOr(Schema.String)), + project_id: Schema.optional(Schema.NullOr(Schema.String)), + file_name: Schema.optional(Schema.NullOr(Schema.String)), + dependency_type: Schema.Literals([ + "required", + "optional", + "incompatible", + "embedded", + ]), + }), + ), + ), + game_versions: Schema.Array(Schema.String), + version_type: Schema.Literals(["release", "beta", "alpha"]), + loaders: Schema.Array(Schema.String), + featured: Schema.Boolean, + status: Schema.optional( + Schema.Literals([ + "listed", + "archived", + "draft", + "unlisted", + "scheduled", + "unknown", + ]), + ), + requested_status: Schema.optional( + Schema.NullOr( + Schema.Literals(["listed", "archived", "draft", "unlisted"]), + ), + ), + id: Schema.String, + project_id: Schema.String, + author_id: Schema.String, + date_published: Schema.String, + downloads: Schema.Number, + changelog_url: Schema.optional(Schema.NullOr(Schema.String)), + files: Schema.Array( + Schema.Struct({ + hashes: Schema.Struct({ + sha512: Schema.optional(Schema.String), + sha1: Schema.optional(Schema.String), + }), + url: Schema.String, + filename: Schema.String, + primary: Schema.Boolean, + size: Schema.Number, + file_type: Schema.optional(Schema.Struct({})), + }), + ), + }); +export type GetVersionFromIdOrNumberOutput = + typeof GetVersionFromIdOrNumberOutput.Type; + +// The operation +/** + * Get a version given a version number or ID + * + * Please note that, if the version number provided matches multiple versions, only the **oldest matching version** will be returned. + * + * @param id_or_slug - The ID or slug of the project + * @param id_or_number - The version ID or version number + */ +export const getVersionFromIdOrNumber = /*@__PURE__*/ /*#__PURE__*/ API.make( + () => ({ + inputSchema: GetVersionFromIdOrNumberInput, + outputSchema: GetVersionFromIdOrNumberOutput, + errors: [NotFound] as const, + }), +); diff --git a/packages/modrinth/src/operations/getVersions.ts b/packages/modrinth/src/operations/getVersions.ts new file mode 100644 index 000000000..494703f2c --- /dev/null +++ b/packages/modrinth/src/operations/getVersions.ts @@ -0,0 +1,85 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest } from "../errors.ts"; + +// Input Schema +export const GetVersionsInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + ids: Schema.String, +}).pipe(T.Http({ method: "GET", path: "/versions" })); +export type GetVersionsInput = typeof GetVersionsInput.Type; + +// Output Schema +export const GetVersionsOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Array( + Schema.Struct({ + name: Schema.String, + version_number: Schema.String, + changelog: Schema.optional(Schema.NullOr(Schema.String)), + dependencies: Schema.optional( + Schema.Array( + Schema.Struct({ + version_id: Schema.optional(Schema.NullOr(Schema.String)), + project_id: Schema.optional(Schema.NullOr(Schema.String)), + file_name: Schema.optional(Schema.NullOr(Schema.String)), + dependency_type: Schema.Literals([ + "required", + "optional", + "incompatible", + "embedded", + ]), + }), + ), + ), + game_versions: Schema.Array(Schema.String), + version_type: Schema.Literals(["release", "beta", "alpha"]), + loaders: Schema.Array(Schema.String), + featured: Schema.Boolean, + status: Schema.optional( + Schema.Literals([ + "listed", + "archived", + "draft", + "unlisted", + "scheduled", + "unknown", + ]), + ), + requested_status: Schema.optional( + Schema.NullOr( + Schema.Literals(["listed", "archived", "draft", "unlisted"]), + ), + ), + id: Schema.String, + project_id: Schema.String, + author_id: Schema.String, + date_published: Schema.String, + downloads: Schema.Number, + changelog_url: Schema.optional(Schema.NullOr(Schema.String)), + files: Schema.Array( + Schema.Struct({ + hashes: Schema.Struct({ + sha512: Schema.optional(Schema.String), + sha1: Schema.optional(Schema.String), + }), + url: Schema.String, + filename: Schema.String, + primary: Schema.Boolean, + size: Schema.Number, + file_type: Schema.optional(Schema.Struct({})), + }), + ), + }), +); +export type GetVersionsOutput = typeof GetVersionsOutput.Type; + +// The operation +/** + * Get multiple versions + * + * @param ids - The IDs of the versions + */ +export const getVersions = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: GetVersionsInput, + outputSchema: GetVersionsOutput, + errors: [BadRequest] as const, +})); diff --git a/packages/modrinth/src/operations/index.ts b/packages/modrinth/src/operations/index.ts new file mode 100644 index 000000000..13c1f8f9e --- /dev/null +++ b/packages/modrinth/src/operations/index.ts @@ -0,0 +1,76 @@ +export * from "./searchProjects.ts"; +export * from "./getProject.ts"; +export * from "./modifyProject.ts"; +export * from "./deleteProject.ts"; +export * from "./getProjects.ts"; +export * from "./patchProjects.ts"; +export * from "./randomProjects.ts"; +export * from "./createProject.ts"; +export * from "./changeProjectIcon.ts"; +export * from "./deleteProjectIcon.ts"; +export * from "./checkProjectValidity.ts"; +export * from "./addGalleryImage.ts"; +export * from "./modifyGalleryImage.ts"; +export * from "./deleteGalleryImage.ts"; +export * from "./getDependencies.ts"; +export * from "./followProject.ts"; +export * from "./unfollowProject.ts"; +export * from "./scheduleProject.ts"; +export * from "./getProjectVersions.ts"; +export * from "./getVersion.ts"; +export * from "./modifyVersion.ts"; +export * from "./deleteVersion.ts"; +export * from "./getVersionFromIdOrNumber.ts"; +export * from "./createVersion.ts"; +export * from "./scheduleVersion.ts"; +export * from "./getVersions.ts"; +export * from "./addFilesToVersion.ts"; +export * from "./versionFromHash.ts"; +export * from "./deleteFileFromHash.ts"; +export * from "./getLatestVersionFromHash.ts"; +export * from "./versionsFromHashes.ts"; +export * from "./getLatestVersionsFromHashes.ts"; +export * from "./getUser.ts"; +export * from "./modifyUser.ts"; +export * from "./getUserFromAuth.ts"; +export * from "./getUsers.ts"; +export * from "./changeUserIcon.ts"; +export * from "./deleteUserIcon.ts"; +export * from "./getUserProjects.ts"; +export * from "./getFollowedProjects.ts"; +export * from "./getPayoutHistory.ts"; +export * from "./withdrawPayout.ts"; +export * from "./getUserNotifications.ts"; +export * from "./getNotification.ts"; +export * from "./readNotification.ts"; +export * from "./deleteNotification.ts"; +export * from "./getNotifications.ts"; +export * from "./readNotifications.ts"; +export * from "./deleteNotifications.ts"; +export * from "./getOpenReports.ts"; +export * from "./submitReport.ts"; +export * from "./getReport.ts"; +export * from "./modifyReport.ts"; +export * from "./getReports.ts"; +export * from "./getThread.ts"; +export * from "./sendThreadMessage.ts"; +export * from "./getThreads.ts"; +export * from "./deleteThreadMessage.ts"; +export * from "./getProjectTeamMembers.ts"; +export * from "./getTeamMembers.ts"; +export * from "./addTeamMember.ts"; +export * from "./getTeams.ts"; +export * from "./joinTeam.ts"; +export * from "./modifyTeamMember.ts"; +export * from "./deleteTeamMember.ts"; +export * from "./transferTeamOwnership.ts"; +export * from "./categoryList.ts"; +export * from "./loaderList.ts"; +export * from "./versionList.ts"; +export * from "./licenseText.ts"; +export * from "./donationPlatformList.ts"; +export * from "./reportTypeList.ts"; +export * from "./projectTypeList.ts"; +export * from "./sideTypeList.ts"; +export * from "./forgeUpdates.ts"; +export * from "./statistics.ts"; diff --git a/packages/modrinth/src/operations/joinTeam.ts b/packages/modrinth/src/operations/joinTeam.ts new file mode 100644 index 000000000..eb2e77e57 --- /dev/null +++ b/packages/modrinth/src/operations/joinTeam.ts @@ -0,0 +1,26 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, NotFound } from "../errors.ts"; + +// Input Schema +export const JoinTeamInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id: Schema.String.pipe(T.PathParam()), +}).pipe(T.Http({ method: "POST", path: "/team/{id}/join" })); +export type JoinTeamInput = typeof JoinTeamInput.Type; + +// Output Schema +export const JoinTeamOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type JoinTeamOutput = typeof JoinTeamOutput.Type; + +// The operation +/** + * Join a team + * + * @param id - The ID of the team + */ +export const joinTeam = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: JoinTeamInput, + outputSchema: JoinTeamOutput, + errors: [BadRequest, NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/licenseText.ts b/packages/modrinth/src/operations/licenseText.ts new file mode 100644 index 000000000..68cacd255 --- /dev/null +++ b/packages/modrinth/src/operations/licenseText.ts @@ -0,0 +1,29 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest } from "../errors.ts"; + +// Input Schema +export const LicenseTextInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id: Schema.String.pipe(T.PathParam()), +}).pipe(T.Http({ method: "GET", path: "/tag/license/{id}" })); +export type LicenseTextInput = typeof LicenseTextInput.Type; + +// Output Schema +export const LicenseTextOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + title: Schema.optional(Schema.String), + body: Schema.optional(Schema.String), +}); +export type LicenseTextOutput = typeof LicenseTextOutput.Type; + +// The operation +/** + * Get the text and title of a license + * + * @param id - The license ID to get the text of + */ +export const licenseText = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: LicenseTextInput, + outputSchema: LicenseTextOutput, + errors: [BadRequest] as const, +})); diff --git a/packages/modrinth/src/operations/loaderList.ts b/packages/modrinth/src/operations/loaderList.ts new file mode 100644 index 000000000..5983eff00 --- /dev/null +++ b/packages/modrinth/src/operations/loaderList.ts @@ -0,0 +1,30 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; + +// Input Schema +export const LoaderListInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct( + {}, +).pipe(T.Http({ method: "GET", path: "/tag/loader" })); +export type LoaderListInput = typeof LoaderListInput.Type; + +// Output Schema +export const LoaderListOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Array( + Schema.Struct({ + icon: Schema.String, + name: Schema.String, + supported_project_types: Schema.Array(Schema.String), + }), +); +export type LoaderListOutput = typeof LoaderListOutput.Type; + +// The operation +/** + * Get a list of loaders + * + * Gets an array of loaders, their icons, and supported project types + */ +export const loaderList = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: LoaderListInput, + outputSchema: LoaderListOutput, +})); diff --git a/packages/modrinth/src/operations/modifyGalleryImage.ts b/packages/modrinth/src/operations/modifyGalleryImage.ts new file mode 100644 index 000000000..d5b15a339 --- /dev/null +++ b/packages/modrinth/src/operations/modifyGalleryImage.ts @@ -0,0 +1,37 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, NotFound } from "../errors.ts"; + +// Input Schema +export const ModifyGalleryImageInput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id_or_slug: Schema.String.pipe(T.PathParam()), + url: Schema.String, + featured: Schema.optional(Schema.Boolean), + title: Schema.optional(Schema.String), + description: Schema.optional(Schema.String), + ordering: Schema.optional(Schema.Number), + }).pipe(T.Http({ method: "PATCH", path: "/project/{id_or_slug}/gallery" })); +export type ModifyGalleryImageInput = typeof ModifyGalleryImageInput.Type; + +// Output Schema +export const ModifyGalleryImageOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type ModifyGalleryImageOutput = typeof ModifyGalleryImageOutput.Type; + +// The operation +/** + * Modify a gallery image + * + * @param id_or_slug - The ID or slug of the project + * @param url - URL link of the image to modify + * @param featured - Whether the image is featured + * @param title - New title of the image + * @param description - New description of the image + * @param ordering - New ordering of the image + */ +export const modifyGalleryImage = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: ModifyGalleryImageInput, + outputSchema: ModifyGalleryImageOutput, + errors: [BadRequest, NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/modifyProject.ts b/packages/modrinth/src/operations/modifyProject.ts new file mode 100644 index 000000000..99a1ec4ac --- /dev/null +++ b/packages/modrinth/src/operations/modifyProject.ts @@ -0,0 +1,28 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, NotFound } from "../errors.ts"; + +// Input Schema +export const ModifyProjectInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id_or_slug: Schema.String.pipe(T.PathParam()), + moderation_message: Schema.optional(Schema.NullOr(Schema.String)), + moderation_message_body: Schema.optional(Schema.NullOr(Schema.String)), +}).pipe(T.Http({ method: "PATCH", path: "/project/{id_or_slug}" })); +export type ModifyProjectInput = typeof ModifyProjectInput.Type; + +// Output Schema +export const ModifyProjectOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type ModifyProjectOutput = typeof ModifyProjectOutput.Type; + +// The operation +/** + * Modify a project + * + * @param id_or_slug - The ID or slug of the project + */ +export const modifyProject = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: ModifyProjectInput, + outputSchema: ModifyProjectOutput, + errors: [BadRequest, NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/modifyReport.ts b/packages/modrinth/src/operations/modifyReport.ts new file mode 100644 index 000000000..4f6197608 --- /dev/null +++ b/packages/modrinth/src/operations/modifyReport.ts @@ -0,0 +1,28 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, NotFound } from "../errors.ts"; + +// Input Schema +export const ModifyReportInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id: Schema.String.pipe(T.PathParam()), + body: Schema.optional(Schema.String), + closed: Schema.optional(Schema.Boolean), +}).pipe(T.Http({ method: "PATCH", path: "/report/{id}" })); +export type ModifyReportInput = typeof ModifyReportInput.Type; + +// Output Schema +export const ModifyReportOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type ModifyReportOutput = typeof ModifyReportOutput.Type; + +// The operation +/** + * Modify a report + * + * @param id - The ID of the report + */ +export const modifyReport = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: ModifyReportInput, + outputSchema: ModifyReportOutput, + errors: [BadRequest, NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/modifyTeamMember.ts b/packages/modrinth/src/operations/modifyTeamMember.ts new file mode 100644 index 000000000..005b70a24 --- /dev/null +++ b/packages/modrinth/src/operations/modifyTeamMember.ts @@ -0,0 +1,34 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, NotFound } from "../errors.ts"; + +// Input Schema +export const ModifyTeamMemberInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id: Schema.String.pipe(T.PathParam()), + id_or_username: Schema.String.pipe(T.PathParam()), + role: Schema.optional(Schema.String), + permissions: Schema.optional(Schema.Number), + payouts_split: Schema.optional(Schema.Number), + ordering: Schema.optional(Schema.Number), +}).pipe( + T.Http({ method: "PATCH", path: "/team/{id}/members/{id_or_username}" }), +); +export type ModifyTeamMemberInput = typeof ModifyTeamMemberInput.Type; + +// Output Schema +export const ModifyTeamMemberOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type ModifyTeamMemberOutput = typeof ModifyTeamMemberOutput.Type; + +// The operation +/** + * Modify a team member's information + * + * @param id - The ID of the team + * @param id_or_username - The ID or username of the user + */ +export const modifyTeamMember = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: ModifyTeamMemberInput, + outputSchema: ModifyTeamMemberOutput, + errors: [BadRequest, NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/modifyUser.ts b/packages/modrinth/src/operations/modifyUser.ts new file mode 100644 index 000000000..9876bd4a7 --- /dev/null +++ b/packages/modrinth/src/operations/modifyUser.ts @@ -0,0 +1,42 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, NotFound } from "../errors.ts"; + +// Input Schema +export const ModifyUserInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id_or_username: Schema.String.pipe(T.PathParam()), + username: Schema.String, + name: Schema.optional(Schema.NullOr(Schema.String)), + email: Schema.optional(Schema.NullOr(Schema.String)), + bio: Schema.optional(Schema.String), + payout_data: Schema.optional( + Schema.NullOr( + Schema.Struct({ + balance: Schema.optional(Schema.Number), + payout_wallet: Schema.optional(Schema.Literals(["paypal", "venmo"])), + payout_wallet_type: Schema.optional( + Schema.Literals(["email", "phone", "user_handle"]), + ), + payout_address: Schema.optional(Schema.String), + }), + ), + ), +}).pipe(T.Http({ method: "PATCH", path: "/user/{id_or_username}" })); +export type ModifyUserInput = typeof ModifyUserInput.Type; + +// Output Schema +export const ModifyUserOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type ModifyUserOutput = typeof ModifyUserOutput.Type; + +// The operation +/** + * Modify a user + * + * @param id_or_username - The ID or username of the user + */ +export const modifyUser = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: ModifyUserInput, + outputSchema: ModifyUserOutput, + errors: [BadRequest, NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/modifyVersion.ts b/packages/modrinth/src/operations/modifyVersion.ts new file mode 100644 index 000000000..05db4d2f7 --- /dev/null +++ b/packages/modrinth/src/operations/modifyVersion.ts @@ -0,0 +1,71 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, NotFound } from "../errors.ts"; + +// Input Schema +export const ModifyVersionInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id: Schema.String.pipe(T.PathParam()), + name: Schema.optional(Schema.String), + version_number: Schema.optional(Schema.String), + changelog: Schema.optional(Schema.NullOr(Schema.String)), + dependencies: Schema.optional( + Schema.Array( + Schema.Struct({ + version_id: Schema.optional(Schema.NullOr(Schema.String)), + project_id: Schema.optional(Schema.NullOr(Schema.String)), + file_name: Schema.optional(Schema.NullOr(Schema.String)), + dependency_type: Schema.Literals([ + "required", + "optional", + "incompatible", + "embedded", + ]), + }), + ), + ), + game_versions: Schema.optional(Schema.Array(Schema.String)), + version_type: Schema.optional(Schema.Literals(["release", "beta", "alpha"])), + loaders: Schema.optional(Schema.Array(Schema.String)), + featured: Schema.optional(Schema.Boolean), + status: Schema.optional( + Schema.Literals([ + "listed", + "archived", + "draft", + "unlisted", + "scheduled", + "unknown", + ]), + ), + requested_status: Schema.optional( + Schema.NullOr(Schema.Literals(["listed", "archived", "draft", "unlisted"])), + ), + primary_file: Schema.optional(Schema.Array(Schema.String)), + file_types: Schema.optional( + Schema.Array( + Schema.Struct({ + algorithm: Schema.String, + hash: Schema.String, + file_type: Schema.Struct({}), + }), + ), + ), +}).pipe(T.Http({ method: "PATCH", path: "/version/{id}" })); +export type ModifyVersionInput = typeof ModifyVersionInput.Type; + +// Output Schema +export const ModifyVersionOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type ModifyVersionOutput = typeof ModifyVersionOutput.Type; + +// The operation +/** + * Modify a version + * + * @param id - The ID of the version + */ +export const modifyVersion = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: ModifyVersionInput, + outputSchema: ModifyVersionOutput, + errors: [BadRequest, NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/patchProjects.ts b/packages/modrinth/src/operations/patchProjects.ts new file mode 100644 index 000000000..6d53a8fba --- /dev/null +++ b/packages/modrinth/src/operations/patchProjects.ts @@ -0,0 +1,63 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest } from "../errors.ts"; + +// Input Schema +export const PatchProjectsInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + ids: Schema.String, + categories: Schema.optional(Schema.Array(Schema.String)), + add_categories: Schema.optional(Schema.Array(Schema.String)), + remove_categories: Schema.optional(Schema.Array(Schema.String)), + additional_categories: Schema.optional(Schema.Array(Schema.String)), + add_additional_categories: Schema.optional(Schema.Array(Schema.String)), + remove_additional_categories: Schema.optional(Schema.Array(Schema.String)), + donation_urls: Schema.optional( + Schema.Array( + Schema.Struct({ + id: Schema.optional(Schema.String), + platform: Schema.optional(Schema.String), + url: Schema.optional(Schema.String), + }), + ), + ), + add_donation_urls: Schema.optional( + Schema.Array( + Schema.Struct({ + id: Schema.optional(Schema.String), + platform: Schema.optional(Schema.String), + url: Schema.optional(Schema.String), + }), + ), + ), + remove_donation_urls: Schema.optional( + Schema.Array( + Schema.Struct({ + id: Schema.optional(Schema.String), + platform: Schema.optional(Schema.String), + url: Schema.optional(Schema.String), + }), + ), + ), + issues_url: Schema.optional(Schema.NullOr(Schema.String)), + source_url: Schema.optional(Schema.NullOr(Schema.String)), + wiki_url: Schema.optional(Schema.NullOr(Schema.String)), + discord_url: Schema.optional(Schema.NullOr(Schema.String)), +}).pipe(T.Http({ method: "PATCH", path: "/projects" })); +export type PatchProjectsInput = typeof PatchProjectsInput.Type; + +// Output Schema +export const PatchProjectsOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type PatchProjectsOutput = typeof PatchProjectsOutput.Type; + +// The operation +/** + * Bulk-edit multiple projects + * + * @param ids - The IDs and/or slugs of the projects + */ +export const patchProjects = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: PatchProjectsInput, + outputSchema: PatchProjectsOutput, + errors: [BadRequest] as const, +})); diff --git a/packages/modrinth/src/operations/projectTypeList.ts b/packages/modrinth/src/operations/projectTypeList.ts new file mode 100644 index 000000000..5ca112cd6 --- /dev/null +++ b/packages/modrinth/src/operations/projectTypeList.ts @@ -0,0 +1,26 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; + +// Input Schema +export const ProjectTypeListInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct( + {}, +).pipe(T.Http({ method: "GET", path: "/tag/project_type" })); +export type ProjectTypeListInput = typeof ProjectTypeListInput.Type; + +// Output Schema +export const ProjectTypeListOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Array( + Schema.String, +); +export type ProjectTypeListOutput = typeof ProjectTypeListOutput.Type; + +// The operation +/** + * Get a list of project types + * + * Gets an array of valid project types + */ +export const projectTypeList = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: ProjectTypeListInput, + outputSchema: ProjectTypeListOutput, +})); diff --git a/packages/modrinth/src/operations/randomProjects.ts b/packages/modrinth/src/operations/randomProjects.ts new file mode 100644 index 000000000..2c7481e64 --- /dev/null +++ b/packages/modrinth/src/operations/randomProjects.ts @@ -0,0 +1,69 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest } from "../errors.ts"; + +// Input Schema +export const RandomProjectsInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + count: Schema.Number, +}).pipe(T.Http({ method: "GET", path: "/projects_random" })); +export type RandomProjectsInput = typeof RandomProjectsInput.Type; + +// Output Schema +export const RandomProjectsOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Array( + Schema.Struct({ + id: Schema.String, + team: Schema.String, + body_url: Schema.optional(Schema.NullOr(Schema.String)), + moderator_message: Schema.optional( + Schema.NullOr( + Schema.Struct({ + message: Schema.optional(Schema.String), + body: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + published: Schema.String, + updated: Schema.String, + approved: Schema.optional(Schema.NullOr(Schema.String)), + queued: Schema.optional(Schema.NullOr(Schema.String)), + followers: Schema.Number, + license: Schema.optional( + Schema.Struct({ + id: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), + url: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + versions: Schema.optional(Schema.Array(Schema.String)), + game_versions: Schema.optional(Schema.Array(Schema.String)), + loaders: Schema.optional(Schema.Array(Schema.String)), + gallery: Schema.optional( + Schema.Array( + Schema.NullOr( + Schema.Struct({ + url: Schema.String, + featured: Schema.Boolean, + title: Schema.optional(Schema.NullOr(Schema.String)), + description: Schema.optional(Schema.NullOr(Schema.String)), + created: Schema.String, + ordering: Schema.optional(Schema.Number), + }), + ), + ), + ), + }), +); +export type RandomProjectsOutput = typeof RandomProjectsOutput.Type; + +// The operation +/** + * Get a list of random projects + * + * @param count - The number of random projects to return + */ +export const randomProjects = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: RandomProjectsInput, + outputSchema: RandomProjectsOutput, + errors: [BadRequest] as const, +})); diff --git a/packages/modrinth/src/operations/readNotification.ts b/packages/modrinth/src/operations/readNotification.ts new file mode 100644 index 000000000..2782077b9 --- /dev/null +++ b/packages/modrinth/src/operations/readNotification.ts @@ -0,0 +1,26 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, NotFound } from "../errors.ts"; + +// Input Schema +export const ReadNotificationInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id: Schema.String.pipe(T.PathParam()), +}).pipe(T.Http({ method: "PATCH", path: "/notification/{id}" })); +export type ReadNotificationInput = typeof ReadNotificationInput.Type; + +// Output Schema +export const ReadNotificationOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type ReadNotificationOutput = typeof ReadNotificationOutput.Type; + +// The operation +/** + * Mark notification as read + * + * @param id - The ID of the notification + */ +export const readNotification = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: ReadNotificationInput, + outputSchema: ReadNotificationOutput, + errors: [BadRequest, NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/readNotifications.ts b/packages/modrinth/src/operations/readNotifications.ts new file mode 100644 index 000000000..b35a7bd3d --- /dev/null +++ b/packages/modrinth/src/operations/readNotifications.ts @@ -0,0 +1,28 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, NotFound } from "../errors.ts"; + +// Input Schema +export const ReadNotificationsInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct( + { + ids: Schema.String, + }, +).pipe(T.Http({ method: "PATCH", path: "/notifications" })); +export type ReadNotificationsInput = typeof ReadNotificationsInput.Type; + +// Output Schema +export const ReadNotificationsOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type ReadNotificationsOutput = typeof ReadNotificationsOutput.Type; + +// The operation +/** + * Mark multiple notifications as read + * + * @param ids - The IDs of the notifications + */ +export const readNotifications = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: ReadNotificationsInput, + outputSchema: ReadNotificationsOutput, + errors: [BadRequest, NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/reportTypeList.ts b/packages/modrinth/src/operations/reportTypeList.ts new file mode 100644 index 000000000..6dd77243d --- /dev/null +++ b/packages/modrinth/src/operations/reportTypeList.ts @@ -0,0 +1,26 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; + +// Input Schema +export const ReportTypeListInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct( + {}, +).pipe(T.Http({ method: "GET", path: "/tag/report_type" })); +export type ReportTypeListInput = typeof ReportTypeListInput.Type; + +// Output Schema +export const ReportTypeListOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Array( + Schema.String, +); +export type ReportTypeListOutput = typeof ReportTypeListOutput.Type; + +// The operation +/** + * Get a list of report types + * + * Gets an array of valid report types + */ +export const reportTypeList = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: ReportTypeListInput, + outputSchema: ReportTypeListOutput, +})); diff --git a/packages/modrinth/src/operations/scheduleProject.ts b/packages/modrinth/src/operations/scheduleProject.ts new file mode 100644 index 000000000..86442392c --- /dev/null +++ b/packages/modrinth/src/operations/scheduleProject.ts @@ -0,0 +1,34 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest } from "../errors.ts"; + +// Input Schema +export const ScheduleProjectInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id_or_slug: Schema.String.pipe(T.PathParam()), + time: Schema.String, + requested_status: Schema.Literals([ + "approved", + "archived", + "unlisted", + "private", + "draft", + ]), +}).pipe(T.Http({ method: "POST", path: "/project/{id_or_slug}/schedule" })); +export type ScheduleProjectInput = typeof ScheduleProjectInput.Type; + +// Output Schema +export const ScheduleProjectOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type ScheduleProjectOutput = typeof ScheduleProjectOutput.Type; + +// The operation +/** + * Schedule a project + * + * @param id_or_slug - The ID or slug of the project + */ +export const scheduleProject = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: ScheduleProjectInput, + outputSchema: ScheduleProjectOutput, + errors: [BadRequest] as const, +})); diff --git a/packages/modrinth/src/operations/scheduleVersion.ts b/packages/modrinth/src/operations/scheduleVersion.ts new file mode 100644 index 000000000..7440af626 --- /dev/null +++ b/packages/modrinth/src/operations/scheduleVersion.ts @@ -0,0 +1,34 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest } from "../errors.ts"; + +// Input Schema +export const ScheduleVersionInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id: Schema.String.pipe(T.PathParam()), + time: Schema.String, + requested_status: Schema.Literals([ + "approved", + "archived", + "unlisted", + "private", + "draft", + ]), +}).pipe(T.Http({ method: "POST", path: "/version/{id}/schedule" })); +export type ScheduleVersionInput = typeof ScheduleVersionInput.Type; + +// Output Schema +export const ScheduleVersionOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type ScheduleVersionOutput = typeof ScheduleVersionOutput.Type; + +// The operation +/** + * Schedule a version + * + * @param id - The ID of the version + */ +export const scheduleVersion = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: ScheduleVersionInput, + outputSchema: ScheduleVersionOutput, + errors: [BadRequest] as const, +})); diff --git a/packages/modrinth/src/operations/searchProjects.ts b/packages/modrinth/src/operations/searchProjects.ts new file mode 100644 index 000000000..a7c792eb8 --- /dev/null +++ b/packages/modrinth/src/operations/searchProjects.ts @@ -0,0 +1,101 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest } from "../errors.ts"; + +// Input Schema +export const SearchProjectsInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + query: Schema.optional(Schema.String), + facets: Schema.optional(Schema.String), + index: Schema.optional( + Schema.Literals(["relevance", "downloads", "follows", "newest", "updated"]), + ), + offset: Schema.optional(Schema.Number), + limit: Schema.optional(Schema.Number), +}).pipe(T.Http({ method: "GET", path: "/search" })); +export type SearchProjectsInput = typeof SearchProjectsInput.Type; + +// Output Schema +export const SearchProjectsOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + hits: Schema.Array( + Schema.Struct({ + project_id: Schema.String, + author: Schema.String, + display_categories: Schema.optional(Schema.Array(Schema.String)), + versions: Schema.Array(Schema.String), + follows: Schema.Number, + date_created: Schema.String, + date_modified: Schema.String, + latest_version: Schema.optional(Schema.String), + license: Schema.String, + gallery: Schema.optional(Schema.Array(Schema.String)), + featured_gallery: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + offset: Schema.Number, + limit: Schema.Number, + total_hits: Schema.Number, +}); +export type SearchProjectsOutput = typeof SearchProjectsOutput.Type; + +// The operation +/** + * Search projects + * + * @param query - The query to search for + * @param facets - Facets are an essential concept for understanding how to filter out results. + +These are the most commonly used facet types: +- `project_type` +- `categories` (loaders are lumped in with categories in search) +- `versions` +- `client_side` +- `server_side` +- `open_source` + +Several others are also available for use, though these should not be used outside very specific use cases. +- `title` +- `author` +- `follows` +- `project_id` +- `license` +- `downloads` +- `color` +- `created_timestamp` (uses Unix timestamp) +- `modified_timestamp` (uses Unix timestamp) +- `date_created` (uses ISO-8601 timestamp) +- `date_modified` (uses ISO-8601 timestamp) + +In order to then use these facets, you need a value to filter by, as well as an operation to perform on this value. +The most common operation is `:` (same as `=`), though you can also use `!=`, `>=`, `>`, `<=`, and `<`. +Join together the type, operation, and value, and you've got your string. +``` +{type} {operation} {value} +``` + +Examples: +``` +categories = adventure +versions != 1.20.1 +downloads <= 100 +``` + +You then join these strings together in arrays to signal `AND` and `OR` operators. + +##### OR +All elements in a single array are considered to be joined by OR statements. +For example, the search `[["versions:1.16.5", "versions:1.17.1"]]` translates to `Projects that support 1.16.5 OR 1.17.1`. + +##### AND +Separate arrays are considered to be joined by AND statements. +For example, the search `[["versions:1.16.5"], ["project_type:modpack"]]` translates to `Projects that support 1.16.5 AND are modpacks`. + + * @param index - The sorting method used for sorting search results + * @param offset - The offset into the search. Skips this number of results + * @param limit - The number of results returned by the search + */ +export const searchProjects = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: SearchProjectsInput, + outputSchema: SearchProjectsOutput, + errors: [BadRequest] as const, +})); diff --git a/packages/modrinth/src/operations/sendThreadMessage.ts b/packages/modrinth/src/operations/sendThreadMessage.ts new file mode 100644 index 000000000..1de7e3242 --- /dev/null +++ b/packages/modrinth/src/operations/sendThreadMessage.ts @@ -0,0 +1,151 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, NotFound } from "../errors.ts"; + +// Input Schema +export const SendThreadMessageInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct( + { + id: Schema.String.pipe(T.PathParam()), + type: Schema.Literals([ + "status_change", + "text", + "thread_closure", + "deleted", + ]), + body: Schema.optional(Schema.String), + private: Schema.optional(Schema.Boolean), + replying_to: Schema.optional(Schema.NullOr(Schema.String)), + old_status: Schema.optional( + Schema.Literals([ + "approved", + "archived", + "rejected", + "draft", + "unlisted", + "processing", + "withheld", + "scheduled", + "private", + "unknown", + ]), + ), + new_status: Schema.optional( + Schema.Literals([ + "approved", + "archived", + "rejected", + "draft", + "unlisted", + "processing", + "withheld", + "scheduled", + "private", + "unknown", + ]), + ), + }, +).pipe(T.Http({ method: "POST", path: "/thread/{id}" })); +export type SendThreadMessageInput = typeof SendThreadMessageInput.Type; + +// Output Schema +export const SendThreadMessageOutput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id: Schema.String, + type: Schema.Literals(["project", "report", "direct_message"]), + project_id: Schema.optional(Schema.NullOr(Schema.String)), + report_id: Schema.optional(Schema.NullOr(Schema.String)), + messages: Schema.Array( + Schema.Struct({ + id: Schema.String, + author_id: Schema.optional(Schema.NullOr(Schema.String)), + body: Schema.Struct({ + type: Schema.Literals([ + "status_change", + "text", + "thread_closure", + "deleted", + ]), + body: Schema.optional(Schema.String), + private: Schema.optional(Schema.Boolean), + replying_to: Schema.optional(Schema.NullOr(Schema.String)), + old_status: Schema.optional( + Schema.Literals([ + "approved", + "archived", + "rejected", + "draft", + "unlisted", + "processing", + "withheld", + "scheduled", + "private", + "unknown", + ]), + ), + new_status: Schema.optional( + Schema.Literals([ + "approved", + "archived", + "rejected", + "draft", + "unlisted", + "processing", + "withheld", + "scheduled", + "private", + "unknown", + ]), + ), + }), + created: Schema.String, + }), + ), + members: Schema.Array( + Schema.Struct({ + username: Schema.String, + name: Schema.optional(Schema.NullOr(Schema.String)), + email: Schema.optional(Schema.NullOr(Schema.String)), + bio: Schema.optional(Schema.String), + payout_data: Schema.optional( + Schema.NullOr( + Schema.Struct({ + balance: Schema.optional(Schema.Number), + payout_wallet: Schema.optional( + Schema.Literals(["paypal", "venmo"]), + ), + payout_wallet_type: Schema.optional( + Schema.Literals(["email", "phone", "user_handle"]), + ), + payout_address: Schema.optional(Schema.String), + }), + ), + ), + id: Schema.String, + avatar_url: Schema.String, + created: Schema.String, + role: Schema.Literals(["admin", "moderator", "developer"]), + badges: Schema.optional(Schema.Number), + auth_providers: Schema.optional( + Schema.NullOr(Schema.Array(Schema.String)), + ), + email_verified: Schema.optional(Schema.NullOr(Schema.Boolean)), + has_password: Schema.optional(Schema.NullOr(Schema.Boolean)), + has_totp: Schema.optional(Schema.NullOr(Schema.Boolean)), + github_id: Schema.optional(Schema.NullOr(Schema.Number)), + }), + ), + }); +export type SendThreadMessageOutput = typeof SendThreadMessageOutput.Type; + +// The operation +/** + * Send a text message to a thread + * + * @param id - The ID of the thread + */ +export const sendThreadMessage = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: SendThreadMessageInput, + outputSchema: SendThreadMessageOutput, + errors: [BadRequest, NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/sideTypeList.ts b/packages/modrinth/src/operations/sideTypeList.ts new file mode 100644 index 000000000..fff37d9a9 --- /dev/null +++ b/packages/modrinth/src/operations/sideTypeList.ts @@ -0,0 +1,26 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; + +// Input Schema +export const SideTypeListInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct( + {}, +).pipe(T.Http({ method: "GET", path: "/tag/side_type" })); +export type SideTypeListInput = typeof SideTypeListInput.Type; + +// Output Schema +export const SideTypeListOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Array( + Schema.String, +); +export type SideTypeListOutput = typeof SideTypeListOutput.Type; + +// The operation +/** + * Get a list of side types + * + * Gets an array of valid side types + */ +export const sideTypeList = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: SideTypeListInput, + outputSchema: SideTypeListOutput, +})); diff --git a/packages/modrinth/src/operations/statistics.ts b/packages/modrinth/src/operations/statistics.ts new file mode 100644 index 000000000..c7de1cca9 --- /dev/null +++ b/packages/modrinth/src/operations/statistics.ts @@ -0,0 +1,27 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; + +// Input Schema +export const StatisticsInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct( + {}, +).pipe(T.Http({ method: "GET", path: "/statistics" })); +export type StatisticsInput = typeof StatisticsInput.Type; + +// Output Schema +export const StatisticsOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + projects: Schema.optional(Schema.Number), + versions: Schema.optional(Schema.Number), + files: Schema.optional(Schema.Number), + authors: Schema.optional(Schema.Number), +}); +export type StatisticsOutput = typeof StatisticsOutput.Type; + +// The operation +/** + * Various statistics about this Modrinth instance + */ +export const statistics = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: StatisticsInput, + outputSchema: StatisticsOutput, +})); diff --git a/packages/modrinth/src/operations/submitReport.ts b/packages/modrinth/src/operations/submitReport.ts new file mode 100644 index 000000000..5a5fffca1 --- /dev/null +++ b/packages/modrinth/src/operations/submitReport.ts @@ -0,0 +1,39 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest } from "../errors.ts"; + +// Input Schema +export const SubmitReportInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + report_type: Schema.String, + item_id: Schema.String, + item_type: Schema.Literals(["project", "user", "version"]), + body: Schema.String, +}).pipe(T.Http({ method: "POST", path: "/report" })); +export type SubmitReportInput = typeof SubmitReportInput.Type; + +// Output Schema +export const SubmitReportOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + report_type: Schema.String, + item_id: Schema.String, + item_type: Schema.Literals(["project", "user", "version"]), + body: Schema.String, + id: Schema.optional(Schema.String), + reporter: Schema.String, + created: Schema.String, + closed: Schema.Boolean, + thread_id: Schema.String, +}); +export type SubmitReportOutput = typeof SubmitReportOutput.Type; + +// The operation +/** + * Report a project, user, or version + * + * Bring a project, user, or version to the attention of the moderators by reporting it. + */ +export const submitReport = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: SubmitReportInput, + outputSchema: SubmitReportOutput, + errors: [BadRequest] as const, +})); diff --git a/packages/modrinth/src/operations/transferTeamOwnership.ts b/packages/modrinth/src/operations/transferTeamOwnership.ts new file mode 100644 index 000000000..739a4f69b --- /dev/null +++ b/packages/modrinth/src/operations/transferTeamOwnership.ts @@ -0,0 +1,32 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, NotFound } from "../errors.ts"; + +// Input Schema +export const TransferTeamOwnershipInput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id: Schema.String.pipe(T.PathParam()), + user_id: Schema.String, + }).pipe(T.Http({ method: "PATCH", path: "/team/{id}/owner" })); +export type TransferTeamOwnershipInput = typeof TransferTeamOwnershipInput.Type; + +// Output Schema +export const TransferTeamOwnershipOutput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type TransferTeamOwnershipOutput = + typeof TransferTeamOwnershipOutput.Type; + +// The operation +/** + * Transfer team's ownership to another user + * + * @param id - The ID of the team + */ +export const transferTeamOwnership = /*@__PURE__*/ /*#__PURE__*/ API.make( + () => ({ + inputSchema: TransferTeamOwnershipInput, + outputSchema: TransferTeamOwnershipOutput, + errors: [BadRequest, NotFound] as const, + }), +); diff --git a/packages/modrinth/src/operations/unfollowProject.ts b/packages/modrinth/src/operations/unfollowProject.ts new file mode 100644 index 000000000..4d61311cc --- /dev/null +++ b/packages/modrinth/src/operations/unfollowProject.ts @@ -0,0 +1,26 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest } from "../errors.ts"; + +// Input Schema +export const UnfollowProjectInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id_or_slug: Schema.String.pipe(T.PathParam()), +}).pipe(T.Http({ method: "DELETE", path: "/project/{id_or_slug}/follow" })); +export type UnfollowProjectInput = typeof UnfollowProjectInput.Type; + +// Output Schema +export const UnfollowProjectOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type UnfollowProjectOutput = typeof UnfollowProjectOutput.Type; + +// The operation +/** + * Unfollow a project + * + * @param id_or_slug - The ID or slug of the project + */ +export const unfollowProject = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: UnfollowProjectInput, + outputSchema: UnfollowProjectOutput, + errors: [BadRequest] as const, +})); diff --git a/packages/modrinth/src/operations/versionFromHash.ts b/packages/modrinth/src/operations/versionFromHash.ts new file mode 100644 index 000000000..67622fba3 --- /dev/null +++ b/packages/modrinth/src/operations/versionFromHash.ts @@ -0,0 +1,85 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { NotFound } from "../errors.ts"; + +// Input Schema +export const VersionFromHashInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + hash: Schema.String.pipe(T.PathParam()), + algorithm: Schema.Literals(["sha1", "sha512"]), + multiple: Schema.optional(Schema.Boolean), +}).pipe(T.Http({ method: "GET", path: "/version_file/{hash}" })); +export type VersionFromHashInput = typeof VersionFromHashInput.Type; + +// Output Schema +export const VersionFromHashOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + name: Schema.String, + version_number: Schema.String, + changelog: Schema.optional(Schema.NullOr(Schema.String)), + dependencies: Schema.optional( + Schema.Array( + Schema.Struct({ + version_id: Schema.optional(Schema.NullOr(Schema.String)), + project_id: Schema.optional(Schema.NullOr(Schema.String)), + file_name: Schema.optional(Schema.NullOr(Schema.String)), + dependency_type: Schema.Literals([ + "required", + "optional", + "incompatible", + "embedded", + ]), + }), + ), + ), + game_versions: Schema.Array(Schema.String), + version_type: Schema.Literals(["release", "beta", "alpha"]), + loaders: Schema.Array(Schema.String), + featured: Schema.Boolean, + status: Schema.optional( + Schema.Literals([ + "listed", + "archived", + "draft", + "unlisted", + "scheduled", + "unknown", + ]), + ), + requested_status: Schema.optional( + Schema.NullOr(Schema.Literals(["listed", "archived", "draft", "unlisted"])), + ), + id: Schema.String, + project_id: Schema.String, + author_id: Schema.String, + date_published: Schema.String, + downloads: Schema.Number, + changelog_url: Schema.optional(Schema.NullOr(Schema.String)), + files: Schema.Array( + Schema.Struct({ + hashes: Schema.Struct({ + sha512: Schema.optional(Schema.String), + sha1: Schema.optional(Schema.String), + }), + url: Schema.String, + filename: Schema.String, + primary: Schema.Boolean, + size: Schema.Number, + file_type: Schema.optional(Schema.Struct({})), + }), + ), +}); +export type VersionFromHashOutput = typeof VersionFromHashOutput.Type; + +// The operation +/** + * Get version from hash + * + * @param hash - The hash of the file, considering its byte content, and encoded in hexadecimal + * @param algorithm - The algorithm of the hash + * @param multiple - Whether to return multiple results when looking for this hash + */ +export const versionFromHash = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: VersionFromHashInput, + outputSchema: VersionFromHashOutput, + errors: [NotFound] as const, +})); diff --git a/packages/modrinth/src/operations/versionList.ts b/packages/modrinth/src/operations/versionList.ts new file mode 100644 index 000000000..729edc78e --- /dev/null +++ b/packages/modrinth/src/operations/versionList.ts @@ -0,0 +1,31 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; + +// Input Schema +export const VersionListInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct( + {}, +).pipe(T.Http({ method: "GET", path: "/tag/game_version" })); +export type VersionListInput = typeof VersionListInput.Type; + +// Output Schema +export const VersionListOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Array( + Schema.Struct({ + version: Schema.String, + version_type: Schema.Literals(["release", "snapshot", "alpha", "beta"]), + date: Schema.String, + major: Schema.Boolean, + }), +); +export type VersionListOutput = typeof VersionListOutput.Type; + +// The operation +/** + * Get a list of game versions + * + * Gets an array of game versions and information about them + */ +export const versionList = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: VersionListInput, + outputSchema: VersionListOutput, +})); diff --git a/packages/modrinth/src/operations/versionsFromHashes.ts b/packages/modrinth/src/operations/versionsFromHashes.ts new file mode 100644 index 000000000..673558262 --- /dev/null +++ b/packages/modrinth/src/operations/versionsFromHashes.ts @@ -0,0 +1,89 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest } from "../errors.ts"; + +// Input Schema +export const VersionsFromHashesInput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + hashes: Schema.Array(Schema.String), + algorithm: Schema.Literals(["sha1", "sha512"]), + }).pipe(T.Http({ method: "POST", path: "/version_files" })); +export type VersionsFromHashesInput = typeof VersionsFromHashesInput.Type; + +// Output Schema +export const VersionsFromHashesOutput = + /*@__PURE__*/ /*#__PURE__*/ Schema.Record( + Schema.String, + Schema.Struct({ + name: Schema.String, + version_number: Schema.String, + changelog: Schema.optional(Schema.NullOr(Schema.String)), + dependencies: Schema.optional( + Schema.Array( + Schema.Struct({ + version_id: Schema.optional(Schema.NullOr(Schema.String)), + project_id: Schema.optional(Schema.NullOr(Schema.String)), + file_name: Schema.optional(Schema.NullOr(Schema.String)), + dependency_type: Schema.Literals([ + "required", + "optional", + "incompatible", + "embedded", + ]), + }), + ), + ), + game_versions: Schema.Array(Schema.String), + version_type: Schema.Literals(["release", "beta", "alpha"]), + loaders: Schema.Array(Schema.String), + featured: Schema.Boolean, + status: Schema.optional( + Schema.Literals([ + "listed", + "archived", + "draft", + "unlisted", + "scheduled", + "unknown", + ]), + ), + requested_status: Schema.optional( + Schema.NullOr( + Schema.Literals(["listed", "archived", "draft", "unlisted"]), + ), + ), + id: Schema.String, + project_id: Schema.String, + author_id: Schema.String, + date_published: Schema.String, + downloads: Schema.Number, + changelog_url: Schema.optional(Schema.NullOr(Schema.String)), + files: Schema.Array( + Schema.Struct({ + hashes: Schema.Struct({ + sha512: Schema.optional(Schema.String), + sha1: Schema.optional(Schema.String), + }), + url: Schema.String, + filename: Schema.String, + primary: Schema.Boolean, + size: Schema.Number, + file_type: Schema.optional(Schema.Struct({})), + }), + ), + }), + ); +export type VersionsFromHashesOutput = typeof VersionsFromHashesOutput.Type; + +// The operation +/** + * Get versions from hashes + * + * This is the same as [`/version_file/{hash}`](#operation/versionFromHash) except it accepts multiple hashes. + */ +export const versionsFromHashes = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: VersionsFromHashesInput, + outputSchema: VersionsFromHashesOutput, + errors: [BadRequest] as const, +})); diff --git a/packages/modrinth/src/operations/withdrawPayout.ts b/packages/modrinth/src/operations/withdrawPayout.ts new file mode 100644 index 000000000..fcd21ea5a --- /dev/null +++ b/packages/modrinth/src/operations/withdrawPayout.ts @@ -0,0 +1,30 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { NotFound } from "../errors.ts"; + +// Input Schema +export const WithdrawPayoutInput = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ + id_or_username: Schema.String.pipe(T.PathParam()), + amount: Schema.Number, +}).pipe(T.Http({ method: "POST", path: "/user/{id_or_username}/payouts" })); +export type WithdrawPayoutInput = typeof WithdrawPayoutInput.Type; + +// Output Schema +export const WithdrawPayoutOutput = /*@__PURE__*/ /*#__PURE__*/ Schema.Void; +export type WithdrawPayoutOutput = typeof WithdrawPayoutOutput.Type; + +// The operation +/** + * Withdraw payout balance to PayPal or Venmo + * + * Warning: certain amounts get withheld for fees. Please do not call this API endpoint without first acknowledging the warnings on the corresponding frontend page. + * + * @param id_or_username - The ID or username of the user + * @param amount - Amount to withdraw + */ +export const withdrawPayout = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ + inputSchema: WithdrawPayoutInput, + outputSchema: WithdrawPayoutOutput, + errors: [NotFound] as const, +})); diff --git a/packages/modrinth/src/retry.ts b/packages/modrinth/src/retry.ts new file mode 100644 index 000000000..0b40e6c99 --- /dev/null +++ b/packages/modrinth/src/retry.ts @@ -0,0 +1,35 @@ +/** + * Modrinth retry configuration. + */ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +export { + type Options, + type Factory, + type Policy, + makeDefault, + jittered, + capped, + throttlingOptions, + transientOptions, +} from "@distilled.cloud/core/retry"; +import type { Policy } from "@distilled.cloud/core/retry"; + +/** + * Context tag for configuring retry behavior of Modrinth API calls. + */ +export class Retry extends Context.Service()("ModrinthRetry") {} + +/** + * Provides a custom retry policy to all Modrinth API calls. + */ +export const policy = (optionsOrFactory: Policy) => + Effect.provide(Layer.succeed(Retry, optionsOrFactory)); + +/** + * Disables all automatic retries. + */ +export const none = Effect.provide( + Layer.succeed(Retry, { while: () => false }), +); diff --git a/packages/modrinth/src/sensitive.ts b/packages/modrinth/src/sensitive.ts new file mode 100644 index 000000000..2167a39b2 --- /dev/null +++ b/packages/modrinth/src/sensitive.ts @@ -0,0 +1,4 @@ +/** + * Re-export sensitive data schemas from sdk-core. + */ +export * from "@distilled.cloud/core/sensitive"; diff --git a/packages/modrinth/src/traits.ts b/packages/modrinth/src/traits.ts new file mode 100644 index 000000000..cf13e396a --- /dev/null +++ b/packages/modrinth/src/traits.ts @@ -0,0 +1,4 @@ +/** + * Re-export the shared traits system from sdk-core. + */ +export * from "@distilled.cloud/core/traits"; diff --git a/packages/modrinth/test/addFilesToVersion.test.ts b/packages/modrinth/test/addFilesToVersion.test.ts new file mode 100644 index 000000000..451863cad --- /dev/null +++ b/packages/modrinth/test/addFilesToVersion.test.ts @@ -0,0 +1,89 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { NotFound, Unauthorized } from "../src/errors.ts"; +import { addFilesToVersion } from "../src/operations/addFilesToVersion.ts"; +import { getProjectVersions } from "../src/operations/getProjectVersions.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +// The generated input schema for this multipart route is incomplete (the +// `data` field is a placeholder literal and there is no way to attach real +// file binary parts). The happy path therefore needs an env var that points +// at a version the caller owns AND a schema that supports file uploads — the +// latter is tracked separately. With just an owned version id we still get +// the chance to hit the real route under auth, even though the empty body +// will be rejected once the schema is fixed; for now we env-gate the happy +// path on the owned-version id and let it run against the live API. +const OWNED_VERSION_ID = process.env.MODRINTH_TEST_OWNED_VERSION_ID; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("addFilesToVersion", () => { + it.skipIf(!OWNED_VERSION_ID)( + "calls the add-files route on a version the caller owns", + async () => { + // POST /version/{id}/file accepts a multipart body containing one or + // more file parts. The currently generated schema does not yet expose + // a way to attach binary file parts, so we exercise the route with the + // placeholder input the schema offers — this validates that the SDK + // wiring (path params, multipart content-type, auth) reaches Modrinth + // and is accepted as a 2xx (no files = no-op acknowledged). + const id = OWNED_VERSION_ID as string; + await runEffect(addFilesToVersion({ id })); + }, + ); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound for a version id that does not exist", + async () => { + // With auth, a well-formed but non-existent 8-character id yields a 404 + // from the version add-files route. + const id = `zz${testRunId.slice(0, 6)}`; + const error = await runEffect( + addFilesToVersion({ id }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // POST /version/{id}/file requires auth. We fetch a real version id from + // the public `sodium` project so the path resolves and Modrinth reaches + // the auth check, which returns 401 with no API key. + const versions = await runEffect( + getProjectVersions({ + id_or_slug: "sodium", + include_changelog: false, + }), + ); + expect(versions.length).toBeGreaterThan(0); + const id = versions[0]!.id; + + const error = await Effect.runPromise( + addFilesToVersion({ id }).pipe( + Effect.flip, + Effect.provide(NoAuthLayer), + ), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/addGalleryImage.test.ts b/packages/modrinth/test/addGalleryImage.test.ts new file mode 100644 index 000000000..dd8d90205 --- /dev/null +++ b/packages/modrinth/test/addGalleryImage.test.ts @@ -0,0 +1,100 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { BadRequest, NotFound, Unauthorized } from "../src/errors.ts"; +import { addGalleryImage } from "../src/operations/addGalleryImage.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +const OWNED_PROJECT_ID = process.env.MODRINTH_TEST_OWNED_PROJECT_ID; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("addGalleryImage", () => { + it.skipIf(!OWNED_PROJECT_ID)( + "uploads a gallery image to a project the caller owns", + async () => { + // POST /project/{slug}/gallery accepts raw image bytes as the body and + // metadata (ext, featured, title, ...) as query params. Our SDK emits an + // empty body for this schema, but Modrinth still validates the call and + // returns a 204 for owned projects with valid metadata. + const projectId = OWNED_PROJECT_ID as string; + await runEffect( + addGalleryImage({ + id_or_slug: projectId, + ext: "png", + featured: false, + title: `distilled-mr-gallery-${testRunId}`, + description: `distilled gallery upload ${testRunId}`, + ordering: 0, + }), + ); + }, + ); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound for a project slug that does not exist", + async () => { + // With auth, an unknown slug reaches the project lookup and yields 404. + const error = await runEffect( + addGalleryImage({ + id_or_slug: `distilled-mr-missing-${testRunId}`, + ext: "png", + featured: false, + }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + it.skipIf(!OWNED_PROJECT_ID)( + "returns BadRequest when the body fails image validation", + async () => { + // The SDK sends an empty/non-image body; Modrinth's gallery uploader + // rejects it with a 400 invalid_input on owned projects (auth passes, + // image validation fails). + const projectId = OWNED_PROJECT_ID as string; + const error = await runEffect( + addGalleryImage({ + id_or_slug: projectId, + ext: "png", + featured: false, + }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // POST /project/{slug}/gallery requires auth. With a valid known slug and + // valid metadata, Modrinth reaches the auth check and returns 401. + const error = await Effect.runPromise( + addGalleryImage({ + id_or_slug: "sodium", + ext: "png", + featured: false, + }).pipe(Effect.flip, Effect.provide(NoAuthLayer)), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/addTeamMember.test.ts b/packages/modrinth/test/addTeamMember.test.ts new file mode 100644 index 000000000..afcd0df6e --- /dev/null +++ b/packages/modrinth/test/addTeamMember.test.ts @@ -0,0 +1,123 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { BadRequest, NotFound, Unauthorized } from "../src/errors.ts"; +import { addTeamMember } from "../src/operations/addTeamMember.ts"; +import { deleteTeamMember } from "../src/operations/deleteTeamMember.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +// POST /team/{id}/members sends a real team invitation to the target +// user (they receive a Modrinth notification). The happy path is +// triple-gated: API key, the auth'd user must own/admin a team passed +// via MODRINTH_TEST_TEAM_ID, a target user id passed via +// MODRINTH_TEST_INVITE_USER_ID, and an explicit opt-in flag +// MODRINTH_TEST_ALLOW_TEAM_INVITE=1. Cleanup removes the invitee. +const TEAM_ID = process.env.MODRINTH_TEST_TEAM_ID; +const INVITE_USER_ID = process.env.MODRINTH_TEST_INVITE_USER_ID; +const ALLOW_INVITE = process.env.MODRINTH_TEST_ALLOW_TEAM_INVITE === "1"; +const SHOULD_RUN_HAPPY = + HAS_API_KEY && !!TEAM_ID && !!INVITE_USER_ID && ALLOW_INVITE; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("addTeamMember", () => { + it.skipIf(!SHOULD_RUN_HAPPY)( + "invites a user to a team the auth'd caller administers", + async () => { + // Real Modrinth team invitations are visible to the invitee, so + // this test is gated behind explicit env vars (see above). The + // SDK call itself returns 204/Void; we assert that and rely on + // ensuring-style cleanup to remove the invitee whether the + // assertion succeeds or throws. + const teamId = TEAM_ID as string; + const inviteeId = INVITE_USER_ID as string; + + const result = await runEffect( + addTeamMember({ id: teamId, user_id: inviteeId }).pipe( + Effect.ensuring( + // Always remove the invitee after the test completes, even + // on failure. Effect.ignore swallows any failure of the + // delete call itself so cleanup never masks the real + // result. testRunId in resource names doesn't apply here + // because we operate on a pre-existing team passed via env. + deleteTeamMember({ + id: teamId, + id_or_username: inviteeId, + }).pipe(Effect.ignore), + ), + ), + ); + + expect(result).toBeUndefined(); + }, + 30_000, + ); + + it("returns BadRequest for an id that is not valid base62", async () => { + // Modrinth ids are base62-encoded; the path validator rejects ids + // containing non-base62 characters (e.g. `!`) with a + // `400 invalid_input` before any auth or DB lookup, so the typed + // BadRequest is reachable without an API key. This 400 mapping is + // added by patches/002-add-mutation-bad-request.patch.json. + const error = await runEffect( + addTeamMember({ + id: `zz!${testRunId}`, + user_id: `zz${testRunId.slice(0, 6)}`, + }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound for a base62-shaped team id that does not exist", + async () => { + // With auth and a base62-shaped team id Modrinth resolves the + // route, looks up the team, and returns 404 when nothing matches. + // We pad the testRunId so the path validator accepts it and the + // lookup actually fires. + const id = `zz${testRunId.slice(0, 6)}`; + const error = await runEffect( + addTeamMember({ id, user_id: `zz${testRunId.slice(0, 6)}` }).pipe( + Effect.flip, + ), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // POST /team/{id}/members requires auth — only team admins may add + // members. With a base62-shaped id the path validator passes, the + // auth check fires next, and Modrinth returns 401 without an API + // key. + const id = `zz${testRunId.slice(0, 6)}`; + const error = await Effect.runPromise( + addTeamMember({ id, user_id: `zz${testRunId.slice(0, 6)}` }).pipe( + Effect.flip, + Effect.provide(NoAuthLayer), + ), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/categoryList.test.ts b/packages/modrinth/test/categoryList.test.ts new file mode 100644 index 000000000..0f76acea5 --- /dev/null +++ b/packages/modrinth/test/categoryList.test.ts @@ -0,0 +1,57 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { NotFound } from "../src/errors.ts"; +import { categoryList } from "../src/operations/categoryList.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +// Layer that points the SDK at a non-existent path on the real Modrinth +// host. categoryList takes no parameters, so the only way to exercise +// a SDK-mapped error path is to redirect the base URL to a route that +// 404s. This proves the operation's status-code → typed-error mapping +// works on a parameterless GET. +const BogusBaseUrlLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: `https://api.modrinth.com/v2-nonexistent-${testRunId}`, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("categoryList", () => { + it("returns the public list of project categories", async () => { + // GET /tag/category is a public, parameterless read endpoint. Every + // Modrinth deployment has at least the core categories (e.g. for + // mods, modpacks, resourcepacks), so we assert the array shape and + // that each entry carries the four documented string fields. + const categories = await runEffect(categoryList({})); + + expect(Array.isArray(categories)).toBe(true); + expect(categories.length).toBeGreaterThan(0); + for (const category of categories) { + expect(typeof category.icon).toBe("string"); + expect(typeof category.name).toBe("string"); + expect(typeof category.project_type).toBe("string"); + expect(typeof category.header).toBe("string"); + } + }); + + it("returns NotFound when the base URL points to a non-existent path", async () => { + // categoryList has no input parameters, so the only deterministic + // way to provoke a typed error from the SDK is to override the + // base URL to a path that doesn't exist. Modrinth answers any + // unknown route with `404 not_found`, which the SDK maps to the + // typed `NotFound`. + const error = await Effect.runPromise( + categoryList({}).pipe(Effect.flip, Effect.provide(BogusBaseUrlLayer)), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }); +}); diff --git a/packages/modrinth/test/changeProjectIcon.test.ts b/packages/modrinth/test/changeProjectIcon.test.ts new file mode 100644 index 000000000..a3a118150 --- /dev/null +++ b/packages/modrinth/test/changeProjectIcon.test.ts @@ -0,0 +1,71 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { BadRequest, Unauthorized } from "../src/errors.ts"; +import { changeProjectIcon } from "../src/operations/changeProjectIcon.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +const OWNED_PROJECT_ID = process.env.MODRINTH_TEST_OWNED_PROJECT_ID; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("changeProjectIcon", () => { + it.skipIf(!OWNED_PROJECT_ID)( + "changes the icon on a project the caller owns", + async () => { + // The image body itself is sent as raw bytes — handled by Modrinth's + // server even when our SDK leaves the body empty for this PATCH (the + // icon route accepts an empty body as a clear-icon no-op for owners). + const projectId = OWNED_PROJECT_ID as string; + await runEffect( + changeProjectIcon({ id_or_slug: projectId, ext: "png" }), + ); + }, + ); + + it.skipIf(!HAS_API_KEY)( + "returns BadRequest for a non-existent project (path validation)", + async () => { + // With auth, Modrinth validates the project path; an unknown slug yields + // a 400 invalid_input response from the icon route's input validator. + const error = await runEffect( + changeProjectIcon({ + id_or_slug: `distilled-mr-missing-${testRunId}`, + ext: "png", + }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // PATCH /project/{slug}/icon requires auth. With a valid `ext` query + // param Modrinth reaches the auth check and returns 401. + const error = await Effect.runPromise( + changeProjectIcon({ id_or_slug: "sodium", ext: "png" }).pipe( + Effect.flip, + Effect.provide(NoAuthLayer), + ), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/changeUserIcon.test.ts b/packages/modrinth/test/changeUserIcon.test.ts new file mode 100644 index 000000000..1137889f1 --- /dev/null +++ b/packages/modrinth/test/changeUserIcon.test.ts @@ -0,0 +1,80 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { BadRequest, NotFound, Unauthorized } from "../src/errors.ts"; +import { changeUserIcon } from "../src/operations/changeUserIcon.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +const OWNED_USER_ID = process.env.MODRINTH_TEST_OWNED_USER_ID; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("changeUserIcon", () => { + it.skipIf(!OWNED_USER_ID)( + "changes the icon on the authenticated user", + async () => { + // PATCH /user/{id_or_username}/icon takes the new image as the raw + // request body and the file extension as `?ext=`. The SDK leaves the + // body empty for this PATCH; Modrinth's icon route accepts that as a + // clear-icon no-op for owners and returns 204. + const id = OWNED_USER_ID as string; + await runEffect(changeUserIcon({ id_or_username: id, ext: "png" })); + }, + ); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound for a username that does not exist", + async () => { + // With auth and a valid `ext` query param Modrinth resolves the + // route, looks up the user, and returns 404 when the username is + // unknown. + const username = `zz-distilled-${testRunId}`; + const error = await runEffect( + changeUserIcon({ id_or_username: username, ext: "png" }).pipe( + Effect.flip, + ), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // PATCH /user/{id_or_username}/icon requires auth. With a valid `ext` + // query param Modrinth reaches the auth check and returns 401. + const error = await Effect.runPromise( + changeUserIcon({ id_or_username: "jellysquid3", ext: "png" }).pipe( + Effect.flip, + Effect.provide(NoAuthLayer), + ), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); + + // BadRequest note: + // The only `400 invalid_input` Modrinth returns for this route is + // "missing field `ext`" when the `ext` query parameter is absent. The + // SDK schema marks `ext` as required and constrains it to a fixed set + // of `Schema.Literals` — `Schema.encode` rejects any input that omits + // `ext` or supplies a non-allowed value before any HTTP call, so the + // `BadRequest` branch is unreachable through the typed SDK and cannot + // be exercised here without bypassing the SDK entirely. +}); diff --git a/packages/modrinth/test/checkProjectValidity.test.ts b/packages/modrinth/test/checkProjectValidity.test.ts new file mode 100644 index 000000000..5f2dbb98a --- /dev/null +++ b/packages/modrinth/test/checkProjectValidity.test.ts @@ -0,0 +1,29 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; +import { NotFound } from "../src/errors.ts"; +import { checkProjectValidity } from "../src/operations/checkProjectValidity.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +describe("checkProjectValidity", () => { + it("returns the project id for a known stable slug", async () => { + // `sodium` is a public, long-lived Modrinth project; the validity check + // does not require auth and resolves the slug to a project id. + const result = await runEffect(checkProjectValidity({ id_or_slug: "sodium" })); + + expect(typeof result.id).toBe("string"); + expect((result.id ?? "").length).toBeGreaterThan(0); + }); + + it("returns NotFound for a slug that does not exist", async () => { + // A run-id-suffixed slug is guaranteed not to exist on Modrinth and the + // /project/{slug}/check route returns 404 for unknown identifiers. + const error = await runEffect( + checkProjectValidity({ + id_or_slug: `distilled-mr-missing-${testRunId}`, + }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }); +}); diff --git a/packages/modrinth/test/createProject.test.ts b/packages/modrinth/test/createProject.test.ts new file mode 100644 index 000000000..d25ddd9c2 --- /dev/null +++ b/packages/modrinth/test/createProject.test.ts @@ -0,0 +1,94 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { BadRequest, Unauthorized } from "../src/errors.ts"; +import { createProject } from "../src/operations/createProject.ts"; +import { deleteProject } from "../src/operations/deleteProject.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +const validData = (slug: string) => + ({ + project_type: "mod" as const, + slug, + title: `Distilled Test ${testRunId}`, + description: "Distilled SDK integration test project.", + body: "Created by automated tests; safe to delete.", + categories: [], + client_side: "optional" as const, + server_side: "optional" as const, + license_id: "MIT", + is_draft: true, + initial_versions: [], + }); + +describe("createProject", () => { + it.skipIf(!HAS_API_KEY)( + "creates a draft project owned by the caller", + async () => { + const slug = `distilled-mr-cp-${testRunId}`; + const result = await runEffect( + Effect.gen(function* () { + const project = yield* createProject({ data: validData(slug) }); + // Always clean up the freshly created project. + return yield* Effect.succeed(project).pipe( + Effect.ensuring( + deleteProject({ id_or_slug: project.id }).pipe(Effect.ignore), + ), + ); + }), + ); + + expect(typeof result.id).toBe("string"); + expect(result.id.length).toBeGreaterThan(0); + expect(typeof result.team).toBe("string"); + expect(typeof result.published).toBe("string"); + expect(typeof result.updated).toBe("string"); + }, + 60_000, + ); + + it("returns BadRequest when required body fields are missing", async () => { + // Modrinth validates the multipart `data` JSON BEFORE auth, so omitting + // required fields (title, slug, description, etc.) yields 400 even without + // a credential. + const error = await runEffect( + createProject({ data: { project_type: "mod" } }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }); + + it("returns Unauthorized when no API key is provided", async () => { + // With a fully-valid `data` JSON, the request reaches the auth check and + // Modrinth returns 401 because no Authorization header was sent. + const slug = `distilled-mr-cp-unauth-${testRunId}`; + const error = await Effect.runPromise( + createProject({ data: validData(slug) }).pipe( + Effect.flip, + Effect.provide(NoAuthLayer), + ), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/createVersion.test.ts b/packages/modrinth/test/createVersion.test.ts new file mode 100644 index 000000000..4ffb9b864 --- /dev/null +++ b/packages/modrinth/test/createVersion.test.ts @@ -0,0 +1,109 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { BadRequest, Unauthorized } from "../src/errors.ts"; +import { createVersion } from "../src/operations/createVersion.ts"; +import { deleteVersion } from "../src/operations/deleteVersion.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +const OWNED_PROJECT_ID = process.env.MODRINTH_TEST_OWNED_PROJECT_ID; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +const validData = (projectId: string, suffix: string) => ({ + name: `Distilled Test Version ${suffix}`, + version_number: `0.0.0-${suffix}`, + changelog: "Created by automated tests; safe to delete.", + dependencies: [], + game_versions: ["1.21"], + version_type: "release" as const, + loaders: ["fabric"], + featured: false, + status: "draft" as const, + project_id: projectId, + file_parts: [], +}); + +describe("createVersion", () => { + it.skipIf(!OWNED_PROJECT_ID)( + "creates a draft version on a project owned by the caller", + async () => { + // POST /version with status=draft does not require any uploaded file + // parts, so the multipart body only carries the JSON `data` field. + // Modrinth returns the freshly created version metadata. + const projectId = OWNED_PROJECT_ID as string; + const result = await runEffect( + Effect.gen(function* () { + const version = yield* createVersion({ + data: validData(projectId, testRunId), + }); + // Always clean up the freshly created version. + return yield* Effect.succeed(version).pipe( + Effect.ensuring( + deleteVersion({ id: version.id }).pipe(Effect.ignore), + ), + ); + }), + ); + + expect(typeof result.id).toBe("string"); + expect(result.id.length).toBeGreaterThan(0); + expect(result.project_id).toBe(projectId); + expect(result.version_number).toBe(`0.0.0-${testRunId}`); + }, + 60_000, + ); + + it.skipIf(!HAS_API_KEY || !OWNED_PROJECT_ID)( + "returns BadRequest when the body fails server-side validation", + async () => { + // With auth on an owned project, a `release` version with no file + // parts fails the server-side rule that every non-draft version must + // include at least one uploaded file. Modrinth returns 400. + const projectId = OWNED_PROJECT_ID as string; + const error = await runEffect( + createVersion({ + data: { + ...validData(projectId, `bad-${testRunId}`), + status: "listed", + version_type: "release", + file_parts: [], + }, + }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // With a fully-valid `data` JSON the request reaches the auth check and + // Modrinth returns 401 because no Authorization header was sent. + // The project_id can be any well-formed Modrinth id; with no auth the + // request never gets far enough for ownership validation. + const error = await Effect.runPromise( + createVersion({ + data: validData("AANobbMI", `noauth-${testRunId}`), + }).pipe(Effect.flip, Effect.provide(NoAuthLayer)), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/deleteFileFromHash.test.ts b/packages/modrinth/test/deleteFileFromHash.test.ts new file mode 100644 index 000000000..6c00fd97b --- /dev/null +++ b/packages/modrinth/test/deleteFileFromHash.test.ts @@ -0,0 +1,82 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { NotFound, Unauthorized } from "../src/errors.ts"; +import { deleteFileFromHash } from "../src/operations/deleteFileFromHash.ts"; +import { getProjectVersions } from "../src/operations/getProjectVersions.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +// Truly destructive: provide a sha1 hash whose underlying file the caller +// owns and is OK losing — once removed it is gone. +const DELETABLE_FILE_HASH = process.env.MODRINTH_TEST_DELETABLE_FILE_SHA1; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("deleteFileFromHash", () => { + it.skipIf(!DELETABLE_FILE_HASH)( + "deletes a file the caller owns by sha1 hash", + async () => { + // DELETE /version_file/{hash}?algorithm=sha1 returns 204. The hash + // pointed at by the env var is consumed by this run; the caller is + // expected to provide a freshly-uploaded file's hash each time. + const hash = DELETABLE_FILE_HASH as string; + await runEffect(deleteFileFromHash({ hash, algorithm: "sha1" })); + }, + ); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound for a hash that does not match any file", + async () => { + // A 40-character hex string is shaped like a sha1 hash but with the + // testRunId baked in is guaranteed not to match any uploaded file; + // Modrinth returns 404 for it. + const hash = "0".repeat(32) + testRunId; + const error = await runEffect( + deleteFileFromHash({ hash, algorithm: "sha1" }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // DELETE /version_file/{hash} requires auth. We pull a real sha1 hash + // from the public `sodium` project so the path resolves and Modrinth + // reaches the auth check, which returns 401 with no API key. + const versions = await runEffect( + getProjectVersions({ + id_or_slug: "sodium", + include_changelog: false, + }), + ); + expect(versions.length).toBeGreaterThan(0); + const sha1 = versions[0]!.files[0]!.hashes.sha1; + expect(typeof sha1).toBe("string"); + + const error = await Effect.runPromise( + deleteFileFromHash({ hash: sha1 as string, algorithm: "sha1" }).pipe( + Effect.flip, + Effect.provide(NoAuthLayer), + ), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/deleteGalleryImage.test.ts b/packages/modrinth/test/deleteGalleryImage.test.ts new file mode 100644 index 000000000..f6c36811b --- /dev/null +++ b/packages/modrinth/test/deleteGalleryImage.test.ts @@ -0,0 +1,84 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { BadRequest, Unauthorized } from "../src/errors.ts"; +import { addGalleryImage } from "../src/operations/addGalleryImage.ts"; +import { deleteGalleryImage } from "../src/operations/deleteGalleryImage.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +const OWNED_PROJECT_ID = process.env.MODRINTH_TEST_OWNED_PROJECT_ID; +const OWNED_GALLERY_URL = process.env.MODRINTH_TEST_OWNED_GALLERY_URL; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("deleteGalleryImage", () => { + it.skipIf(!OWNED_PROJECT_ID || !OWNED_GALLERY_URL)( + "deletes a gallery image on a project the caller owns", + async () => { + // DELETE /project/{slug}/gallery?url=... removes the gallery image + // identified by url. Owners get 204. We re-add the image afterwards + // so this test stays self-contained on the shared owned project. + const projectId = OWNED_PROJECT_ID as string; + const url = OWNED_GALLERY_URL as string; + await runEffect( + deleteGalleryImage({ id_or_slug: projectId, url }).pipe( + Effect.ensuring( + addGalleryImage({ + id_or_slug: projectId, + ext: "png", + featured: false, + title: `distilled-mr-restore-${testRunId}`, + }).pipe(Effect.ignore), + ), + ), + ); + }, + ); + + it.skipIf(!HAS_API_KEY)( + "returns BadRequest for a gallery url that is not on the project", + async () => { + // With auth, Modrinth validates the gallery url against the project's + // gallery items; an unknown url yields a 400 invalid_input. + const projectId = (OWNED_PROJECT_ID ?? "sodium") as string; + const error = await runEffect( + deleteGalleryImage({ + id_or_slug: projectId, + url: `https://cdn-raw.modrinth.com/data/missing/gallery/missing-${testRunId}.png`, + }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // DELETE /project/{slug}/gallery requires auth. With url as a valid query + // param Modrinth reaches the auth check and returns 401. + const error = await Effect.runPromise( + deleteGalleryImage({ + id_or_slug: "sodium", + url: `https://cdn-raw.modrinth.com/data/sodium/gallery/example-${testRunId}.png`, + }).pipe(Effect.flip, Effect.provide(NoAuthLayer)), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/deleteNotification.test.ts b/packages/modrinth/test/deleteNotification.test.ts new file mode 100644 index 000000000..71c465985 --- /dev/null +++ b/packages/modrinth/test/deleteNotification.test.ts @@ -0,0 +1,88 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { NotFound, Unauthorized } from "../src/errors.ts"; +import { deleteNotification } from "../src/operations/deleteNotification.ts"; +import { getUserNotifications } from "../src/operations/getUserNotifications.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +const OWNED_USER_ID = process.env.MODRINTH_TEST_OWNED_USER_ID; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("deleteNotification", () => { + it.skipIf(!OWNED_USER_ID)( + "deletes an existing notification from the authenticated user", + async () => { + // DELETE /notification/{id} permanently removes the notification + // and returns 204. There is no rollback — the notification is gone + // — but Modrinth user inboxes hold transient items (project + // updates, team invites, etc.) that are safe to drop on a test + // account, and the test is gated on the explicit owned-user opt + // in. We bootstrap by listing the auth'd user's notifications and + // deleting the first one; if the inbox is empty we skip the + // assertion since there is nothing to delete. + const id = OWNED_USER_ID as string; + const inbox = await runEffect(getUserNotifications({ id_or_username: id })); + if (inbox.length === 0) { + return; + } + const notificationId = inbox[0]!.id; + + // Output schema is Schema.Void; success means no thrown error. + await runEffect(deleteNotification({ id: notificationId })); + + // After deletion the same id should no longer be in the inbox. + const after = await runEffect(getUserNotifications({ id_or_username: id })); + expect(after.some((n) => n.id === notificationId)).toBe(false); + }, + ); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound for a base62-shaped id that does not exist", + async () => { + // With auth and a base62-shaped id Modrinth resolves the route, + // looks up the notification, and returns 404 when nothing matches. + // We pad the testRunId to 8 base62 chars so the path validator + // accepts it and the lookup actually fires. + const id = `zz${testRunId.slice(0, 6)}`; + const error = await runEffect( + deleteNotification({ id }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // DELETE /notification/{id} requires auth. With a base62-shaped id + // the path validator passes, the auth check fires next, and Modrinth + // returns 401 without an API key. + const id = `zz${testRunId.slice(0, 6)}`; + const error = await Effect.runPromise( + deleteNotification({ id }).pipe( + Effect.flip, + Effect.provide(NoAuthLayer), + ), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/deleteNotifications.test.ts b/packages/modrinth/test/deleteNotifications.test.ts new file mode 100644 index 000000000..e4c0cd9dd --- /dev/null +++ b/packages/modrinth/test/deleteNotifications.test.ts @@ -0,0 +1,93 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { NotFound, Unauthorized } from "../src/errors.ts"; +import { deleteNotifications } from "../src/operations/deleteNotifications.ts"; +import { getUserNotifications } from "../src/operations/getUserNotifications.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +const OWNED_USER_ID = process.env.MODRINTH_TEST_OWNED_USER_ID; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("deleteNotifications", () => { + it.skipIf(!OWNED_USER_ID)( + "deletes multiple notifications in bulk", + async () => { + // DELETE /notifications?ids=[...] permanently removes every + // matching notification and returns 204. There is no rollback — + // the entries are gone — but Modrinth user inboxes hold transient + // items (project updates, team invites, etc.) that are safe to + // drop on a test account, and the test is gated on the explicit + // owned-user opt in. We bootstrap by listing the auth'd user's + // inbox; if it is empty we still confirm the route accepts an + // empty array and replies with 204. + const id = OWNED_USER_ID as string; + const inbox = await runEffect(getUserNotifications({ id_or_username: id })); + + if (inbox.length === 0) { + await runEffect(deleteNotifications({ ids: JSON.stringify([]) })); + return; + } + + const ids = inbox.slice(0, Math.min(2, inbox.length)).map((n) => n.id); + // Output schema is Schema.Void; success means no thrown error. + await runEffect(deleteNotifications({ ids: JSON.stringify(ids) })); + + // After deletion the same ids should no longer be in the inbox. + const after = await runEffect(getUserNotifications({ id_or_username: id })); + for (const want of ids) { + expect(after.some((n) => n.id === want)).toBe(false); + } + }, + ); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound when none of the requested ids belong to the caller", + async () => { + // With auth Modrinth resolves the route, looks up each id, and + // reports 404 when the bulk request resolves to no matching + // notifications visible to the caller. We use base62-shaped ids + // padded with testRunId to guarantee non-collision with real + // notifications. + const ids = [`zz${testRunId.slice(0, 6)}`, `yy${testRunId.slice(0, 6)}`]; + const error = await runEffect( + deleteNotifications({ ids: JSON.stringify(ids) }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // DELETE /notifications requires auth. Modrinth runs the auth check + // before validating the contents of the `ids` query, so any + // well-formed request (even one with an empty ids array) yields 401 + // with no API key. + const error = await Effect.runPromise( + deleteNotifications({ ids: JSON.stringify([]) }).pipe( + Effect.flip, + Effect.provide(NoAuthLayer), + ), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/deleteProject.test.ts b/packages/modrinth/test/deleteProject.test.ts new file mode 100644 index 000000000..81a43501b --- /dev/null +++ b/packages/modrinth/test/deleteProject.test.ts @@ -0,0 +1,69 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { BadRequest, Unauthorized } from "../src/errors.ts"; +import { deleteProject } from "../src/operations/deleteProject.ts"; +import { runEffect } from "./setup.ts"; + +// Project the test account owns and is willing to have deleted by the test. +// Provided externally so we never destroy something we don't intend to. +const DELETABLE_PROJECT_ID = process.env.MODRINTH_TEST_DELETABLE_PROJECT_ID; + +// Project that the API will refuse to delete with a 400 (e.g. because other +// projects depend on its versions). Provided externally so the test is +// deterministic without having to set up complex prerequisite state. +const PROTECTED_PROJECT_ID = process.env.MODRINTH_TEST_PROTECTED_PROJECT_ID; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("deleteProject", () => { + it.skipIf(!DELETABLE_PROJECT_ID)( + "deletes a project the caller owns", + async () => { + const projectId = DELETABLE_PROJECT_ID as string; + // Output schema is Schema.Void; success means no thrown error. + await runEffect(deleteProject({ id_or_slug: projectId })); + }, + ); + + it.skipIf(!PROTECTED_PROJECT_ID)( + "returns BadRequest when the project cannot be deleted (e.g. has dependents)", + async () => { + const projectId = PROTECTED_PROJECT_ID as string; + const error = await runEffect( + deleteProject({ id_or_slug: projectId }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // DELETE /project/{slug} requires auth. Without a credential, Modrinth + // returns 401 before any project lookup, regardless of the slug given. + const error = await Effect.runPromise( + deleteProject({ id_or_slug: "sodium" }).pipe( + Effect.flip, + Effect.provide(NoAuthLayer), + ), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/deleteProjectIcon.test.ts b/packages/modrinth/test/deleteProjectIcon.test.ts new file mode 100644 index 000000000..6489f592a --- /dev/null +++ b/packages/modrinth/test/deleteProjectIcon.test.ts @@ -0,0 +1,68 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { BadRequest, Unauthorized } from "../src/errors.ts"; +import { deleteProjectIcon } from "../src/operations/deleteProjectIcon.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +const OWNED_PROJECT_ID = process.env.MODRINTH_TEST_OWNED_PROJECT_ID; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("deleteProjectIcon", () => { + it.skipIf(!OWNED_PROJECT_ID)( + "deletes the icon on a project the caller owns", + async () => { + // Output schema is Schema.Void; reaching here means the API responded + // with 204 No Content. Re-running on a project with no icon is itself + // a no-op success. + const projectId = OWNED_PROJECT_ID as string; + await runEffect(deleteProjectIcon({ id_or_slug: projectId })); + }, + ); + + it.skipIf(!HAS_API_KEY)( + "returns BadRequest for an invalid project identifier", + async () => { + // With auth, Modrinth's icon route validates the path identifier and + // surfaces a 400 invalid_input for unparseable slugs/ids. + const error = await runEffect( + deleteProjectIcon({ + id_or_slug: `distilled-mr-missing-${testRunId}`, + }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // DELETE /project/{slug}/icon requires auth. Without a credential the + // server returns 401 before any project lookup. + const error = await Effect.runPromise( + deleteProjectIcon({ id_or_slug: "sodium" }).pipe( + Effect.flip, + Effect.provide(NoAuthLayer), + ), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/deleteTeamMember.test.ts b/packages/modrinth/test/deleteTeamMember.test.ts new file mode 100644 index 000000000..10457694a --- /dev/null +++ b/packages/modrinth/test/deleteTeamMember.test.ts @@ -0,0 +1,129 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { BadRequest, NotFound, Unauthorized } from "../src/errors.ts"; +import { addTeamMember } from "../src/operations/addTeamMember.ts"; +import { deleteTeamMember } from "../src/operations/deleteTeamMember.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +// DELETE /team/{id}/members/{id_or_username} removes a member from a +// real Modrinth team (the invitee receives a notification). The happy +// path is quadruple-gated: API key, a team id the auth'd caller +// administers (MODRINTH_TEST_TEAM_ID), a target user id to add and +// then remove (MODRINTH_TEST_INVITE_USER_ID), and an explicit opt-in +// flag MODRINTH_TEST_ALLOW_TEAM_INVITE=1 (shared with addTeamMember +// since both write to the same /team/{id}/members route family). +const TEAM_ID = process.env.MODRINTH_TEST_TEAM_ID; +const INVITE_USER_ID = process.env.MODRINTH_TEST_INVITE_USER_ID; +const ALLOW_INVITE = process.env.MODRINTH_TEST_ALLOW_TEAM_INVITE === "1"; +const SHOULD_RUN_HAPPY = + HAS_API_KEY && !!TEAM_ID && !!INVITE_USER_ID && ALLOW_INVITE; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("deleteTeamMember", () => { + it.skipIf(!SHOULD_RUN_HAPPY)( + "removes a freshly-invited member from a team the auth'd caller administers", + async () => { + // Bootstrap by inviting the target user via addTeamMember, then + // delete the invitation to exercise the operation under test. The + // delete is the cleanup, so no separate ensuring block is needed + // — the call we are testing is itself the cleanup. + const teamId = TEAM_ID as string; + const inviteeId = INVITE_USER_ID as string; + + // The invite call is the prerequisite. If it fails (e.g. user is + // already on the team), surface that as a setup failure rather + // than silently swallowing — that keeps the test honest. + await runEffect( + addTeamMember({ id: teamId, user_id: inviteeId }).pipe( + // If the user is already a member from a prior aborted run, + // a 400 invalid_input is expected — fall through to the + // delete which is what we actually want to test. Anything + // else propagates and fails the test. + Effect.catch((e) => + e._tag === "BadRequest" ? Effect.void : Effect.fail(e), + ), + ), + ); + + const result = await runEffect( + deleteTeamMember({ id: teamId, id_or_username: inviteeId }), + ); + + // DELETE returns 204/Void. + expect(result).toBeUndefined(); + }, + 30_000, + ); + + it("returns BadRequest for an id that is not valid base62", async () => { + // Modrinth team ids are base62-encoded; the path validator rejects + // ids containing non-base62 characters (e.g. `!`) with a + // `400 invalid_input` before any auth or DB lookup, so the typed + // BadRequest is reachable without an API key. This 400 mapping is + // added by patches/002-add-mutation-bad-request.patch.json. + const error = await runEffect( + deleteTeamMember({ + id: `zz!${testRunId}`, + id_or_username: `user-${testRunId}`, + }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound for a base62-shaped team id that does not exist", + async () => { + // With auth and a base62-shaped team id Modrinth resolves the + // route, looks up the team, and returns 404 when nothing matches. + // We pad the testRunId so the path validator accepts it and the + // lookup actually fires. + const id = `zz${testRunId.slice(0, 6)}`; + const error = await runEffect( + deleteTeamMember({ + id, + id_or_username: `user-${testRunId}`, + }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // DELETE /team/{id}/members/{id_or_username} requires auth — only + // team admins (or the member themselves, leaving) may remove a + // member. With a base62-shaped id the path validator passes, the + // auth check fires next, and Modrinth returns 401 without an API + // key. + const id = `zz${testRunId.slice(0, 6)}`; + const error = await Effect.runPromise( + deleteTeamMember({ + id, + id_or_username: `user-${testRunId}`, + }).pipe(Effect.flip, Effect.provide(NoAuthLayer)), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/deleteThreadMessage.test.ts b/packages/modrinth/test/deleteThreadMessage.test.ts new file mode 100644 index 000000000..518206921 --- /dev/null +++ b/packages/modrinth/test/deleteThreadMessage.test.ts @@ -0,0 +1,124 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { BadRequest, NotFound, Unauthorized } from "../src/errors.ts"; +import { deleteThreadMessage } from "../src/operations/deleteThreadMessage.ts"; +import { getOpenReports } from "../src/operations/getOpenReports.ts"; +import { sendThreadMessage } from "../src/operations/sendThreadMessage.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +// The happy path bootstraps by posting a real message into a live +// Modrinth thread (visible to moderators) and then deleting it. Even +// though we self-clean on the same call we're testing, keep the happy +// path opt-in (mirrors submitReport.test.ts / sendThreadMessage.test.ts +// gating) so CI runs without the env var don't poke at live moderation +// state. +const ALLOW_BOOTSTRAP = + process.env.MODRINTH_TEST_ALLOW_SEND_THREAD_MESSAGE === "1"; +const SHOULD_RUN_HAPPY = HAS_API_KEY && ALLOW_BOOTSTRAP; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("deleteThreadMessage", () => { + it.skipIf(!SHOULD_RUN_HAPPY)( + "deletes a freshly-posted thread message", + async () => { + // Bootstrap: list the auth'd user's open reports, post a probe + // message into the first report's thread, then delete it. If the + // queue is empty we cannot exercise the round-trip and return + // early — the listing call still confirms auth. + const open = await runEffect(getOpenReports({})); + const seed = open.find((r) => typeof r.thread_id === "string"); + if (!seed) { + return; + } + const threadId = seed.thread_id; + const messageBody = `distilled SDK deleteThreadMessage probe — please ignore (run ${testRunId})`; + + const updated = await runEffect( + sendThreadMessage({ + id: threadId, + type: "text", + body: messageBody, + }), + ); + + const posted = updated.messages.find( + (m) => m.body.type === "text" && m.body.body === messageBody, + ); + expect(posted).toBeDefined(); + const messageId = posted!.id; + expect(typeof messageId).toBe("string"); + + // The actual operation under test. DELETE /message/{id} returns + // 204/Void; if the call resolves without an error the message is + // deleted (Modrinth replaces the body with type="deleted" rather + // than removing the entry from the messages list). + const result = await runEffect(deleteThreadMessage({ id: messageId })); + expect(result).toBeUndefined(); + }, + 30_000, + ); + + it("returns BadRequest for an id that is not valid base62", async () => { + // Modrinth ids are base62-encoded; the path validator rejects ids + // containing non-base62 characters (e.g. `!`) with a + // `400 invalid_input` before any auth or DB lookup, so the typed + // BadRequest is reachable without an API key. This 400 mapping is + // added by patches/002-add-mutation-bad-request.patch.json. + const error = await runEffect( + deleteThreadMessage({ id: `zz!${testRunId}` }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound for a base62-shaped id that does not exist", + async () => { + // With auth and a base62-shaped id Modrinth resolves the route, + // looks up the message, and returns 404 when nothing matches. We + // pad the testRunId so the path validator accepts it and the + // lookup actually fires. + const id = `zz${testRunId.slice(0, 6)}`; + const error = await runEffect( + deleteThreadMessage({ id }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // DELETE /message/{id} requires auth. With a base62-shaped id the + // path validator passes, the auth check fires next, and Modrinth + // returns 401 without an API key. + const id = `zz${testRunId.slice(0, 6)}`; + const error = await Effect.runPromise( + deleteThreadMessage({ id }).pipe( + Effect.flip, + Effect.provide(NoAuthLayer), + ), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/deleteUserIcon.test.ts b/packages/modrinth/test/deleteUserIcon.test.ts new file mode 100644 index 000000000..a4d3eea49 --- /dev/null +++ b/packages/modrinth/test/deleteUserIcon.test.ts @@ -0,0 +1,80 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { NotFound, Unauthorized } from "../src/errors.ts"; +import { deleteUserIcon } from "../src/operations/deleteUserIcon.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +const OWNED_USER_ID = process.env.MODRINTH_TEST_OWNED_USER_ID; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("deleteUserIcon", () => { + it.skipIf(!OWNED_USER_ID)( + "removes the avatar of the authenticated user", + async () => { + // DELETE /user/{id_or_username}/icon clears the avatar and returns + // 204. Modrinth treats the route as idempotent — calling it again on + // a user with no avatar still succeeds — so we don't need to restore + // anything afterwards. + const id = OWNED_USER_ID as string; + await runEffect(deleteUserIcon({ id_or_username: id })); + }, + ); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound for a username that does not exist", + async () => { + // With auth Modrinth resolves the route, looks up the user, and + // returns 404 when the username is unknown. + const username = `zz-distilled-${testRunId}`; + const error = await runEffect( + deleteUserIcon({ id_or_username: username }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // DELETE /user/{id_or_username}/icon requires auth. Modrinth runs the + // auth check before any user lookup, so any well-formed username + // (including the public `jellysquid3` account) yields 401 with no API + // key. + const error = await Effect.runPromise( + deleteUserIcon({ id_or_username: "jellysquid3" }).pipe( + Effect.flip, + Effect.provide(NoAuthLayer), + ), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); + + // BadRequest note: + // This DELETE route takes no body and no query parameters — only a + // single path segment for `id_or_username`, which the SDK schema + // accepts as any string. Modrinth's response set for the route is + // `204` (deleted), `401` (no/invalid auth), and `404` (user not + // found); empirically it does not return `400` for any well-formed + // request the SDK can produce, so the typed `BadRequest` branch is + // unreachable here and cannot be exercised without bypassing the SDK + // entirely. +}); diff --git a/packages/modrinth/test/deleteVersion.test.ts b/packages/modrinth/test/deleteVersion.test.ts new file mode 100644 index 000000000..b61226144 --- /dev/null +++ b/packages/modrinth/test/deleteVersion.test.ts @@ -0,0 +1,75 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { NotFound, Unauthorized } from "../src/errors.ts"; +import { deleteVersion } from "../src/operations/deleteVersion.ts"; +import { getProjectVersions } from "../src/operations/getProjectVersions.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +// Truly destructive: provide a version id that is safe to delete (typically +// a draft or unlisted upload created specifically for this test run). +const DELETABLE_VERSION_ID = process.env.MODRINTH_TEST_DELETABLE_VERSION_ID; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("deleteVersion", () => { + it.skipIf(!DELETABLE_VERSION_ID)( + "deletes a version the caller owns", + async () => { + // DELETE /version/{id} returns 204. The version pointed at by the env + // var is consumed by this run; the caller is expected to provide a + // freshly-uploaded version id each time. + const id = DELETABLE_VERSION_ID as string; + await runEffect(deleteVersion({ id })); + }, + ); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound for a version id that does not exist", + async () => { + // With auth, a well-formed but non-existent 8-character id yields a 404 + // from the version DELETE route. + const id = `zz${testRunId.slice(0, 6)}`; + const error = await runEffect(deleteVersion({ id }).pipe(Effect.flip)); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // DELETE /version/{id} requires auth. We fetch a real version id from + // the public `sodium` project so the path resolves and Modrinth reaches + // the auth check, which returns 401 with no API key. + const versions = await runEffect( + getProjectVersions({ + id_or_slug: "sodium", + include_changelog: false, + }), + ); + expect(versions.length).toBeGreaterThan(0); + const id = versions[0]!.id; + + const error = await Effect.runPromise( + deleteVersion({ id }).pipe(Effect.flip, Effect.provide(NoAuthLayer)), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/donationPlatformList.test.ts b/packages/modrinth/test/donationPlatformList.test.ts new file mode 100644 index 000000000..d364428e2 --- /dev/null +++ b/packages/modrinth/test/donationPlatformList.test.ts @@ -0,0 +1,56 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { Credentials, DEFAULT_USER_AGENT } from "../src/credentials.ts"; +import { NotFound } from "../src/errors.ts"; +import { donationPlatformList } from "../src/operations/donationPlatformList.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +// Layer that points the SDK at a non-existent path on the real Modrinth +// host. donationPlatformList takes no parameters, so the only way to +// exercise a SDK-mapped error path is to redirect the base URL to a +// route that 404s. This proves the operation's status-code → +// typed-error mapping works on a parameterless GET. +const BogusBaseUrlLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: `https://api.modrinth.com/v2-nonexistent-${testRunId}`, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("donationPlatformList", () => { + it("returns the public list of donation platforms", async () => { + // GET /tag/donation_platform is a public, parameterless read + // endpoint. Modrinth ships with a baseline set of donation + // platforms (patreon, github sponsors, ko-fi, etc.) so we expect + // a non-empty array; we assert the array shape and the documented + // string fields on each entry. + const platforms = await runEffect(donationPlatformList({})); + + expect(Array.isArray(platforms)).toBe(true); + expect(platforms.length).toBeGreaterThan(0); + for (const platform of platforms) { + expect(typeof platform.short).toBe("string"); + expect(typeof platform.name).toBe("string"); + } + }); + + it("returns NotFound when the base URL points to a non-existent path", async () => { + // donationPlatformList has no input parameters, so the only + // deterministic way to provoke a typed error from the SDK is to + // override the base URL to a path that doesn't exist. Modrinth + // answers any unrecognized route with `404 not_found`, which the + // SDK maps to the typed `NotFound`. + const error = await Effect.runPromise( + donationPlatformList({}).pipe( + Effect.flip, + Effect.provide(BogusBaseUrlLayer), + ), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }); +}); diff --git a/packages/modrinth/test/followProject.test.ts b/packages/modrinth/test/followProject.test.ts new file mode 100644 index 000000000..2481b5baa --- /dev/null +++ b/packages/modrinth/test/followProject.test.ts @@ -0,0 +1,89 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { BadRequest, Unauthorized } from "../src/errors.ts"; +import { followProject } from "../src/operations/followProject.ts"; +import { unfollowProject } from "../src/operations/unfollowProject.ts"; +import { runEffect } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; + +// `sodium` is a public, long-lived Modrinth project that any authenticated +// user can follow. +const TEST_FOLLOW_SLUG = "sodium"; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("followProject", () => { + it.skipIf(!HAS_API_KEY)("follows a public project", async () => { + // POST /project/{slug}/follow returns 204 when the caller starts following + // the project. We unfollow afterwards so the test is self-contained and + // re-runnable without leaving the account in a "following" state. + await runEffect( + // First make sure we're not already following from a previous run; the + // unfollow is best-effort and ignored on errors. + unfollowProject({ id_or_slug: TEST_FOLLOW_SLUG }).pipe(Effect.ignore), + ); + + await runEffect( + followProject({ id_or_slug: TEST_FOLLOW_SLUG }).pipe( + Effect.ensuring( + unfollowProject({ id_or_slug: TEST_FOLLOW_SLUG }).pipe(Effect.ignore), + ), + ), + ); + }); + + it.skipIf(!HAS_API_KEY)( + "returns BadRequest when following a project that is already followed", + async () => { + // The first follow succeeds; a second follow without an unfollow in + // between yields a 400 invalid_input from Modrinth. + await runEffect( + followProject({ id_or_slug: TEST_FOLLOW_SLUG }).pipe(Effect.ignore), + ); + + const error = await runEffect( + followProject({ id_or_slug: TEST_FOLLOW_SLUG }).pipe( + Effect.flip, + Effect.ensuring( + unfollowProject({ id_or_slug: TEST_FOLLOW_SLUG }).pipe( + Effect.ignore, + ), + ), + ), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // POST /project/{slug}/follow requires auth. With a valid known slug + // Modrinth reaches the auth check and returns 401. + const error = await Effect.runPromise( + followProject({ id_or_slug: TEST_FOLLOW_SLUG }).pipe( + Effect.flip, + Effect.provide(NoAuthLayer), + ), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/forgeUpdates.test.ts b/packages/modrinth/test/forgeUpdates.test.ts new file mode 100644 index 000000000..247ba33a2 --- /dev/null +++ b/packages/modrinth/test/forgeUpdates.test.ts @@ -0,0 +1,78 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { Credentials, DEFAULT_USER_AGENT } from "../src/credentials.ts"; +import { BadRequest } from "../src/errors.ts"; +import { forgeUpdates } from "../src/operations/forgeUpdates.ts"; +import { testRunId } from "./setup.ts"; + +// The Forge update-checker manifest is served from the Modrinth API +// root (https://api.modrinth.com/updates/...), NOT from the /v2 prefix +// (which 404s for this route). Tests for this operation override the +// base URL to the root domain so the SDK reaches the real endpoint. +const RootBaseUrlLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: "https://api.modrinth.com", + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +const provideRoot = (effect: Effect.Effect) => + effect.pipe(Effect.provide(RootBaseUrlLayer)); + +describe("forgeUpdates", () => { + it("returns the Forge update manifest for a public project", async () => { + // GET /updates/{id_or_slug}/forge_updates.json is a public read + // endpoint that emits the Forge update-checker manifest. We use + // the well-known `sodium` slug (also used by other tests as a + // stable bootstrap target). All manifest fields are documented + // as optional, so we assert shape only — `homepage`, when + // present, must be a string and `promos` must be an object. + const manifest = await Effect.runPromise( + provideRoot(forgeUpdates({ id_or_slug: "sodium" })), + ); + + expect(typeof manifest).toBe("object"); + expect(manifest).not.toBeNull(); + if (manifest.homepage !== undefined) { + expect(typeof manifest.homepage).toBe("string"); + } + if (manifest.promos !== undefined) { + expect(typeof manifest.promos).toBe("object"); + } + }); + + it("accepts the neoforge=include query parameter", async () => { + // The `neoforge` query param is a closed enum (`only` | `include`). + // Asserting that `include` round-trips proves the operation passes + // the parameter through to the upstream API and parses the + // response shape. + const manifest = await Effect.runPromise( + provideRoot( + forgeUpdates({ id_or_slug: "sodium", neoforge: "include" }), + ), + ); + + expect(typeof manifest).toBe("object"); + expect(manifest).not.toBeNull(); + }); + + it("returns BadRequest for an id_or_slug containing invalid characters", async () => { + // Modrinth validates the path-segment id/slug for the forge + // update-checker route. Characters outside the allowed grammar + // (e.g. `!!!`) cause the API to reject the request with + // `400 invalid_input` ("Invalid input: The specified project + // does not exist!"), which the SDK maps to the typed + // `BadRequest`. A run-scoped suffix ensures the value is unique + // per run while staying invalid. + const slug = `!!!invalid-${testRunId}!!!`; + const error = await Effect.runPromise( + provideRoot(forgeUpdates({ id_or_slug: slug })).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }); +}); diff --git a/packages/modrinth/test/getDependencies.test.ts b/packages/modrinth/test/getDependencies.test.ts new file mode 100644 index 000000000..d69b46c90 --- /dev/null +++ b/packages/modrinth/test/getDependencies.test.ts @@ -0,0 +1,30 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; +import { NotFound } from "../src/errors.ts"; +import { getDependencies } from "../src/operations/getDependencies.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +describe("getDependencies", () => { + it("returns the dependency projects and versions for a known stable slug", async () => { + // `sodium` is a public, long-lived Modrinth project; the dependencies + // route does not require auth and returns the resolved project/version + // arrays it depends on (which may be empty for some projects). + const result = await runEffect(getDependencies({ id_or_slug: "sodium" })); + + expect(Array.isArray(result.projects ?? [])).toBe(true); + expect(Array.isArray(result.versions ?? [])).toBe(true); + }); + + it("returns NotFound for a slug that does not exist", async () => { + // A run-id-suffixed slug is guaranteed not to exist on Modrinth and the + // /project/{slug}/dependencies route returns 404 for unknown identifiers. + const error = await runEffect( + getDependencies({ + id_or_slug: `distilled-mr-missing-${testRunId}`, + }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }); +}); diff --git a/packages/modrinth/test/getFollowedProjects.test.ts b/packages/modrinth/test/getFollowedProjects.test.ts new file mode 100644 index 000000000..14db40e80 --- /dev/null +++ b/packages/modrinth/test/getFollowedProjects.test.ts @@ -0,0 +1,81 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { NotFound, Unauthorized } from "../src/errors.ts"; +import { getFollowedProjects } from "../src/operations/getFollowedProjects.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +const OWNED_USER_ID = process.env.MODRINTH_TEST_OWNED_USER_ID; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("getFollowedProjects", () => { + it.skipIf(!OWNED_USER_ID)( + "returns the followed projects of the authenticated user", + async () => { + // GET /user/{id_or_username}/follows requires auth and returns the + // public list of projects the auth'd user follows. Modrinth allows + // the auth'd user to read their own follows, so we target the + // configured owned user id. + const id = OWNED_USER_ID as string; + const projects = await runEffect( + getFollowedProjects({ id_or_username: id }), + ); + + expect(Array.isArray(projects)).toBe(true); + for (const project of projects) { + expect(typeof project.id).toBe("string"); + expect(project.id.length).toBeGreaterThan(0); + expect(typeof project.team).toBe("string"); + expect(typeof project.published).toBe("string"); + expect(typeof project.updated).toBe("string"); + } + }, + ); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound for a username that does not exist", + async () => { + // With auth Modrinth resolves the route, looks up the user, and + // returns 404 when the username is unknown. + const username = `zz-distilled-${testRunId}`; + const error = await runEffect( + getFollowedProjects({ id_or_username: username }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // GET /user/{id_or_username}/follows requires auth. Modrinth runs the + // auth check before any user lookup, so any well-formed username + // (including the public `jellysquid3` account) yields 401 with no API + // key. + const error = await Effect.runPromise( + getFollowedProjects({ id_or_username: "jellysquid3" }).pipe( + Effect.flip, + Effect.provide(NoAuthLayer), + ), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/getLatestVersionFromHash.test.ts b/packages/modrinth/test/getLatestVersionFromHash.test.ts new file mode 100644 index 000000000..804cb4314 --- /dev/null +++ b/packages/modrinth/test/getLatestVersionFromHash.test.ts @@ -0,0 +1,88 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; +import { NotFound } from "../src/errors.ts"; +import { getLatestVersionFromHash } from "../src/operations/getLatestVersionFromHash.ts"; +import { getProjectVersions } from "../src/operations/getProjectVersions.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +describe("getLatestVersionFromHash", () => { + it("resolves the latest version of sodium from a real sha1 hash", async () => { + // POST /version_file/{hash}/update accepts a sha1/sha512 hash plus loaders + // and game_versions in the body and returns the latest matching version. + // We pull a real (sha1, loaders, game_versions) tuple from the public + // sodium project so the path resolves and Modrinth has a non-empty + // candidate set. + const versions = await runEffect( + getProjectVersions({ + id_or_slug: "sodium", + include_changelog: false, + }), + ); + expect(versions.length).toBeGreaterThan(0); + const head = versions[0]!; + const sha1 = head.files[0]!.hashes.sha1; + expect(typeof sha1).toBe("string"); + + const result = await runEffect( + getLatestVersionFromHash({ + hash: sha1 as string, + algorithm: "sha1", + loaders: [...head.loaders], + game_versions: [...head.game_versions], + }), + ); + + expect(typeof result.id).toBe("string"); + expect(typeof result.project_id).toBe("string"); + expect(result.project_id).toBe(head.project_id); + }); + + it("returns NotFound for a hash that does not match any file", async () => { + // A 40-character hex string is shaped like a sha1 hash but with the + // testRunId baked in is guaranteed not to match any uploaded file; + // Modrinth returns 404 for it before evaluating the loader/game_version + // filters. + const hash = "0".repeat(32) + testRunId; + const error = await runEffect( + getLatestVersionFromHash({ + hash, + algorithm: "sha1", + loaders: ["fabric"], + game_versions: ["1.20.1"], + }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }); + + it("returns NotFound when no version matches the requested loaders/game_versions", async () => { + // The route also returns 404 when the hash resolves but no candidate + // version satisfies the loader/game_version filter. We reuse a real sodium + // sha1 hash and pair it with a loader Modrinth knows about but sodium + // never ships for, so the candidate set is empty and the API responds 404 + // (BadRequest is reserved for malformed bodies that the SDK schema rejects + // before the request leaves the client). + const versions = await runEffect( + getProjectVersions({ + id_or_slug: "sodium", + include_changelog: false, + }), + ); + expect(versions.length).toBeGreaterThan(0); + const sha1 = versions[0]!.files[0]!.hashes.sha1; + expect(typeof sha1).toBe("string"); + + const error = await runEffect( + getLatestVersionFromHash({ + hash: sha1 as string, + algorithm: "sha1", + loaders: ["forge"], + game_versions: ["1.7.10"], + }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }); +}); diff --git a/packages/modrinth/test/getLatestVersionsFromHashes.test.ts b/packages/modrinth/test/getLatestVersionsFromHashes.test.ts new file mode 100644 index 000000000..bf4eb61ea --- /dev/null +++ b/packages/modrinth/test/getLatestVersionsFromHashes.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import { getLatestVersionsFromHashes } from "../src/operations/getLatestVersionsFromHashes.ts"; +import { getProjectVersions } from "../src/operations/getProjectVersions.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +describe("getLatestVersionsFromHashes", () => { + it("resolves the latest version for each matching sha1 hash", async () => { + // POST /version_files/update accepts a list of sha1/sha512 hashes plus + // a loader/game_version filter and returns a Record + // keyed by the hash that matched. We pull two real sha1 hashes off the + // sodium project and use sodium's own loader/game_version set so the + // candidate filter actually resolves. + const versions = await runEffect( + getProjectVersions({ + id_or_slug: "sodium", + include_changelog: false, + }), + ); + expect(versions.length).toBeGreaterThanOrEqual(2); + const head = versions[0]!; + const sha1A = head.files[0]!.hashes.sha1; + const sha1B = versions[1]!.files[0]!.hashes.sha1; + expect(typeof sha1A).toBe("string"); + expect(typeof sha1B).toBe("string"); + + const result = await runEffect( + getLatestVersionsFromHashes({ + hashes: [sha1A as string, sha1B as string], + algorithm: "sha1", + loaders: [...head.loaders], + game_versions: [...head.game_versions], + }), + ); + + expect(typeof result).toBe("object"); + const entries = Object.values(result); + expect(entries.length).toBeGreaterThanOrEqual(1); + for (const version of entries) { + expect(version.project_id).toBe(head.project_id); + expect(typeof version.id).toBe("string"); + expect(typeof version.version_number).toBe("string"); + } + }); + + it("resolves the latest version for each matching sha512 hash", async () => { + // The same route accepts sha512 hashes when `algorithm` is "sha512". + const versions = await runEffect( + getProjectVersions({ + id_or_slug: "sodium", + include_changelog: false, + }), + ); + expect(versions.length).toBeGreaterThan(0); + const head = versions[0]!; + const sha512 = head.files[0]!.hashes.sha512; + expect(typeof sha512).toBe("string"); + + const result = await runEffect( + getLatestVersionsFromHashes({ + hashes: [sha512 as string], + algorithm: "sha512", + loaders: [...head.loaders], + game_versions: [...head.game_versions], + }), + ); + + const entries = Object.values(result); + expect(entries.length).toBe(1); + expect(entries[0]!.project_id).toBe(head.project_id); + }); + + it("returns an empty record when no hash matches the loader/game_version filter", async () => { + // Real sodium hashes paired with a loader/game_version sodium has never + // shipped for produces an empty candidate set. Modrinth answers 200 with + // an empty object — this is the bulk-route equivalent of NotFound for + // the single-hash endpoint and is *not* a BadRequest. + const versions = await runEffect( + getProjectVersions({ + id_or_slug: "sodium", + include_changelog: false, + }), + ); + expect(versions.length).toBeGreaterThan(0); + const sha1 = versions[0]!.files[0]!.hashes.sha1; + expect(typeof sha1).toBe("string"); + + const result = await runEffect( + getLatestVersionsFromHashes({ + hashes: [sha1 as string], + algorithm: "sha1", + loaders: ["forge"], + game_versions: ["1.7.10"], + }), + ); + + expect(result).toEqual({}); + }); + + it("returns an empty record for hashes that do not match any uploaded file", async () => { + // A 40-character hex string shaped like a sha1 hash with testRunId baked + // in cannot collide with any real file. Modrinth still responds 200 with + // an empty record rather than 4xx for unmatched hashes. + const hash = "0".repeat(32) + testRunId; + const result = await runEffect( + getLatestVersionsFromHashes({ + hashes: [hash], + algorithm: "sha1", + loaders: ["fabric"], + game_versions: ["1.20.1"], + }), + ); + + expect(result).toEqual({}); + }); + + // BadRequest note: + // Modrinth's `/version_files/update` route only returns 400 when the + // request body is missing required fields, contains non-string elements + // in `hashes`/`loaders`/`game_versions`, or is not a JSON struct at all. + // The SDK's typed input schema (`Schema.Array(Schema.String)` for the + // three string-array fields and `Schema.Literals(["sha1", "sha512"])` + // for `algorithm`) rejects every one of those shapes at `Schema.encode` + // time before any HTTP request leaves the client, so there is no input + // the typed SDK can send that Modrinth answers with a 400. Such failures + // surface as a synchronous `SchemaError`, not a `BadRequest`, so the + // `BadRequest` branch is unreachable through the SDK and cannot be + // exercised here without bypassing the SDK entirely. +}); diff --git a/packages/modrinth/test/getNotification.test.ts b/packages/modrinth/test/getNotification.test.ts new file mode 100644 index 000000000..2fc22d695 --- /dev/null +++ b/packages/modrinth/test/getNotification.test.ts @@ -0,0 +1,100 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { BadRequest, NotFound, Unauthorized } from "../src/errors.ts"; +import { getNotification } from "../src/operations/getNotification.ts"; +import { getUserNotifications } from "../src/operations/getUserNotifications.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +const OWNED_USER_ID = process.env.MODRINTH_TEST_OWNED_USER_ID; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("getNotification", () => { + it.skipIf(!OWNED_USER_ID)( + "fetches a single notification belonging to the authenticated user", + async () => { + // GET /notification/{id} requires auth and returns the matching + // notification. We bootstrap by listing the auth'd user's + // notifications and using the first id; if the inbox is empty we + // skip the assertion since there is nothing real to fetch. + const id = OWNED_USER_ID as string; + const inbox = await runEffect(getUserNotifications({ id_or_username: id })); + if (inbox.length === 0) { + // No notifications to round-trip — nothing meaningful to assert, + // but we still confirm the listing request succeeded above. + return; + } + const notificationId = inbox[0]!.id; + + const result = await runEffect(getNotification({ id: notificationId })); + + expect(result.id).toBe(notificationId); + expect(typeof result.user_id).toBe("string"); + expect(typeof result.title).toBe("string"); + expect(typeof result.text).toBe("string"); + expect(typeof result.link).toBe("string"); + expect(typeof result.read).toBe("boolean"); + expect(typeof result.created).toBe("string"); + expect(Array.isArray(result.actions)).toBe(true); + }, + ); + + it("returns BadRequest for an id that is not valid base62", async () => { + // Modrinth ids are base62-encoded; the path validator rejects ids + // containing non-base62 characters (e.g. `!`) with a + // `400 invalid_input` before any auth or DB lookup, so the typed + // BadRequest is reachable without an API key. + const error = await runEffect( + getNotification({ id: `zz!${testRunId}` }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound for a base62-shaped id that does not exist", + async () => { + // With auth and a base62-shaped id Modrinth resolves the route, + // looks up the notification, and returns 404 when nothing matches. + // We pad the testRunId to 8 base62 chars so the path validator + // accepts it and the lookup actually fires. + const id = `zz${testRunId.slice(0, 6)}`; + const error = await runEffect( + getNotification({ id }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // GET /notification/{id} requires auth. With a base62-shaped id the + // path validator passes, the auth check fires next, and Modrinth + // returns 401 without an API key. + const id = `zz${testRunId.slice(0, 6)}`; + const error = await Effect.runPromise( + getNotification({ id }).pipe(Effect.flip, Effect.provide(NoAuthLayer)), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/getNotifications.test.ts b/packages/modrinth/test/getNotifications.test.ts new file mode 100644 index 000000000..304689dc7 --- /dev/null +++ b/packages/modrinth/test/getNotifications.test.ts @@ -0,0 +1,94 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { NotFound, Unauthorized } from "../src/errors.ts"; +import { getNotifications } from "../src/operations/getNotifications.ts"; +import { getUserNotifications } from "../src/operations/getUserNotifications.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +const OWNED_USER_ID = process.env.MODRINTH_TEST_OWNED_USER_ID; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("getNotifications", () => { + it.skipIf(!OWNED_USER_ID)( + "returns the requested notifications for a list of valid ids", + async () => { + // GET /notifications?ids=[...] reads `ids` as a JSON-encoded array + // and returns the matching notifications. We bootstrap by listing + // the auth'd user's inbox and round-tripping the first id; if the + // inbox is empty we still confirm the bulk route accepts an empty + // array and replies with an empty list. + const id = OWNED_USER_ID as string; + const inbox = await runEffect(getUserNotifications({ id_or_username: id })); + + if (inbox.length === 0) { + const empty = await runEffect( + getNotifications({ ids: JSON.stringify([]) }), + ); + expect(Array.isArray(empty)).toBe(true); + expect(empty.length).toBe(0); + return; + } + + const ids = inbox.slice(0, Math.min(2, inbox.length)).map((n) => n.id); + const notifications = await runEffect( + getNotifications({ ids: JSON.stringify(ids) }), + ); + + expect(Array.isArray(notifications)).toBe(true); + expect(notifications.length).toBe(ids.length); + for (const want of ids) { + expect(notifications.some((n) => n.id === want)).toBe(true); + } + }, + ); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound when none of the requested ids belong to the caller", + async () => { + // With auth Modrinth resolves the route, looks up each id, and + // reports 404 when the bulk request resolves to no matching + // notifications visible to the caller. We use base62-shaped ids + // padded with testRunId to guarantee non-collision with real + // notifications. + const ids = [`zz${testRunId.slice(0, 6)}`, `yy${testRunId.slice(0, 6)}`]; + const error = await runEffect( + getNotifications({ ids: JSON.stringify(ids) }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // GET /notifications requires auth. Modrinth runs the auth check + // before validating the `ids` query, so any well-formed request + // (even one with an empty ids array) yields 401 with no API key. + const error = await Effect.runPromise( + getNotifications({ ids: JSON.stringify([]) }).pipe( + Effect.flip, + Effect.provide(NoAuthLayer), + ), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/getOpenReports.test.ts b/packages/modrinth/test/getOpenReports.test.ts new file mode 100644 index 000000000..d29599c9e --- /dev/null +++ b/packages/modrinth/test/getOpenReports.test.ts @@ -0,0 +1,90 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { Unauthorized } from "../src/errors.ts"; +import { getOpenReports } from "../src/operations/getOpenReports.ts"; +import { runEffect } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("getOpenReports", () => { + it.skipIf(!HAS_API_KEY)( + "returns the open reports filed by the authenticated user", + async () => { + // GET /report lists the auth'd user's open (non-closed) reports. The + // list may legitimately be empty for accounts that have never filed a + // report, so we only assert on shape/types of any items returned. + const reports = await runEffect(getOpenReports({})); + + expect(Array.isArray(reports)).toBe(true); + for (const r of reports) { + expect(typeof r.report_type).toBe("string"); + expect(typeof r.item_id).toBe("string"); + expect(["project", "user", "version"]).toContain(r.item_type); + expect(typeof r.body).toBe("string"); + expect(typeof r.reporter).toBe("string"); + expect(typeof r.created).toBe("string"); + expect(typeof r.closed).toBe("boolean"); + expect(typeof r.thread_id).toBe("string"); + if (r.id !== undefined) { + expect(typeof r.id).toBe("string"); + } + } + }, + ); + + it.skipIf(!HAS_API_KEY)( + "respects the count query parameter", + async () => { + // The optional `count` query bounds how many items the server returns. + // We don't know how many reports the authenticated account has, so the + // returned array length is `<= count`. + const reports = await runEffect(getOpenReports({ count: 5 })); + + expect(Array.isArray(reports)).toBe(true); + expect(reports.length).toBeLessThanOrEqual(5); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // GET /report requires authentication. Modrinth answers any unauth'd + // (or invalid-token) call with `401 unauthorized "Authentication method + // was not valid"`, mapped by the SDK to the typed `Unauthorized`. + const error = await Effect.runPromise( + getOpenReports({}).pipe(Effect.flip, Effect.provide(NoAuthLayer)), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); + + // NotFound note: + // The Modrinth spec lists NotFound (404) as a possible response for + // GET /report, but the route has no path parameter and no resource + // lookup — it always returns the auth'd user's reports as a (possibly + // empty) array. Empirically the only non-2xx responses Modrinth emits + // for this endpoint are: + // - 400 invalid_input (e.g. count=-1, count=abc, count=999999...) + // - 401 unauthorized (no/empty/invalid Authorization header) + // There is no input the typed SDK can produce that surfaces a 404 for + // this operation, so the spec-listed NotFound branch is unreachable + // through valid SDK usage and intentionally has no test here. (If + // Modrinth ever changes the route to require a resource lookup that + // can 404, a NotFound test should be added at that time.) +}); diff --git a/packages/modrinth/test/getPayoutHistory.test.ts b/packages/modrinth/test/getPayoutHistory.test.ts new file mode 100644 index 000000000..50d8e3f4f --- /dev/null +++ b/packages/modrinth/test/getPayoutHistory.test.ts @@ -0,0 +1,73 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; +import { NotFound } from "../src/errors.ts"; +import { getPayoutHistory } from "../src/operations/getPayoutHistory.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +const OWNED_USER_ID = process.env.MODRINTH_TEST_OWNED_USER_ID; + +describe("getPayoutHistory", () => { + it.skipIf(!OWNED_USER_ID)( + "returns the payout history of the authenticated user", + async () => { + // GET /user/{id_or_username}/payouts requires auth and returns the + // caller's lifetime/last-month payout totals plus a list of payout + // entries. The payout list may legitimately be empty for users who + // have never received a payout, so we only assert on the response + // shape rather than on specific values. + const id = OWNED_USER_ID as string; + const result = await runEffect(getPayoutHistory({ id_or_username: id })); + + expect(typeof result).toBe("object"); + if (result.payouts !== undefined) { + expect(Array.isArray(result.payouts)).toBe(true); + for (const payout of result.payouts) { + if (payout.amount !== undefined) { + expect(typeof payout.amount).toBe("number"); + } + if (payout.created !== undefined) { + expect(typeof payout.created).toBe("string"); + } + if (payout.status !== undefined) { + expect(typeof payout.status).toBe("string"); + } + } + } + }, + ); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound for a username that does not exist", + async () => { + // With auth Modrinth resolves the route, looks up the user, and + // returns 404 when the username is unknown. Note: this route also + // returns 404 to unauth'd callers (see Unauthorized note below), so + // the assertion only meaningfully distinguishes "user not found" + // from "route hidden" when run with a valid API key. + const username = `zz-distilled-${testRunId}`; + const error = await runEffect( + getPayoutHistory({ id_or_username: username }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + // Unauthorized note: + // Modrinth's /user/{id}/payouts route is one of the few endpoints that + // does *not* return 401 for unauthenticated requests. Instead, the API + // returns `404 not_found "the requested route does not exist"` for + // every request without a valid authentication token (no header, + // empty header, or an invalid token shape). Empirically: + // - no Authorization header → 404 not_found + // - empty Authorization header → 404 not_found + // - syntactically-valid invalid token → 404 not_found + // The SDK's typed `matchError` faithfully maps the 404 response to + // `NotFound`, so there is no input the SDK can produce that surfaces + // the typed `Unauthorized` branch for this operation. The route hides + // itself from unauth'd callers as an information-leak protection, + // which makes `Unauthorized` unreachable through this typed + // operation. +}); diff --git a/packages/modrinth/test/getProject.test.ts b/packages/modrinth/test/getProject.test.ts new file mode 100644 index 000000000..ded90bcd6 --- /dev/null +++ b/packages/modrinth/test/getProject.test.ts @@ -0,0 +1,45 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; +import { NotFound } from "../src/errors.ts"; +import { getProject } from "../src/operations/getProject.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +// A well-known, long-lived public Modrinth project used as a stable read fixture. +// `sodium` has been published and approved on Modrinth for years. +const STABLE_PROJECT_SLUG = "sodium"; + +describe("getProject", () => { + it("returns a project when fetched by slug", async () => { + const result = await runEffect( + getProject({ id_or_slug: STABLE_PROJECT_SLUG }), + ); + + expect(typeof result.id).toBe("string"); + expect(result.id.length).toBeGreaterThan(0); + expect(typeof result.team).toBe("string"); + expect(typeof result.published).toBe("string"); + expect(typeof result.updated).toBe("string"); + expect(typeof result.followers).toBe("number"); + }); + + it("returns the same project when fetched by id (round-trip via slug)", async () => { + const bySlug = await runEffect( + getProject({ id_or_slug: STABLE_PROJECT_SLUG }), + ); + const byId = await runEffect(getProject({ id_or_slug: bySlug.id })); + + expect(byId.id).toBe(bySlug.id); + expect(byId.team).toBe(bySlug.team); + }); + + it("returns NotFound for a non-existent project slug", async () => { + const error = await runEffect( + getProject({ + id_or_slug: `distilled-mr-missing-${testRunId}`, + }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }); +}); diff --git a/packages/modrinth/test/getProjectTeamMembers.test.ts b/packages/modrinth/test/getProjectTeamMembers.test.ts new file mode 100644 index 000000000..0c780d88a --- /dev/null +++ b/packages/modrinth/test/getProjectTeamMembers.test.ts @@ -0,0 +1,42 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; +import { NotFound } from "../src/errors.ts"; +import { getProjectTeamMembers } from "../src/operations/getProjectTeamMembers.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +describe("getProjectTeamMembers", () => { + it("returns the team members of a public project", async () => { + // GET /project/{id_or_slug}/members is a public read endpoint. We + // round-trip the well-known `sodium` slug (also used by + // getVersions.test.ts as a stable bootstrap target) and assert the + // response shape — every project has at least one team member. + const members = await runEffect( + getProjectTeamMembers({ id_or_slug: "sodium" }), + ); + + expect(Array.isArray(members)).toBe(true); + expect(members.length).toBeGreaterThan(0); + const first = members[0]!; + expect(typeof first.team_id).toBe("string"); + expect(typeof first.role).toBe("string"); + expect(typeof first.accepted).toBe("boolean"); + expect(typeof first.user.id).toBe("string"); + expect(typeof first.user.username).toBe("string"); + expect(typeof first.user.avatar_url).toBe("string"); + expect(typeof first.user.created).toBe("string"); + expect(["admin", "moderator", "developer"]).toContain(first.user.role); + }); + + it("returns NotFound for a slug that does not resolve to a project", async () => { + // Modrinth resolves project slugs/ids on this route and returns 404 + // when nothing matches. A run-scoped slug guarantees the lookup + // misses and triggers the typed `NotFound`. + const slug = `distilled-no-such-project-${testRunId}`; + const error = await runEffect( + getProjectTeamMembers({ id_or_slug: slug }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }); +}); diff --git a/packages/modrinth/test/getProjectVersions.test.ts b/packages/modrinth/test/getProjectVersions.test.ts new file mode 100644 index 000000000..424ddf115 --- /dev/null +++ b/packages/modrinth/test/getProjectVersions.test.ts @@ -0,0 +1,56 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; +import { NotFound } from "../src/errors.ts"; +import { getProjectVersions } from "../src/operations/getProjectVersions.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +describe("getProjectVersions", () => { + it("returns the version list for a known stable slug", async () => { + // `sodium` is a public, long-lived Modrinth project with many published + // versions; the version list route does not require auth. + const versions = await runEffect( + getProjectVersions({ + id_or_slug: "sodium", + include_changelog: false, + }), + ); + + expect(Array.isArray(versions)).toBe(true); + expect(versions.length).toBeGreaterThan(0); + const first = versions[0]!; + expect(typeof first.id).toBe("string"); + expect(typeof first.project_id).toBe("string"); + expect(typeof first.version_number).toBe("string"); + expect(Array.isArray(first.files)).toBe(true); + }); + + it("filters versions by loader", async () => { + // The `loaders` query param accepts a JSON-encoded array of loader names. + // Filtering `sodium` to fabric must yield only fabric-tagged versions. + const versions = await runEffect( + getProjectVersions({ + id_or_slug: "sodium", + loaders: JSON.stringify(["fabric"]), + include_changelog: false, + }), + ); + + expect(Array.isArray(versions)).toBe(true); + for (const v of versions) { + expect(v.loaders).toContain("fabric"); + } + }); + + it("returns NotFound for a slug that does not exist", async () => { + // A run-id-suffixed slug is guaranteed not to exist on Modrinth and the + // /project/{slug}/version route returns 404 for unknown identifiers. + const error = await runEffect( + getProjectVersions({ + id_or_slug: `distilled-mr-missing-${testRunId}`, + }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }); +}); diff --git a/packages/modrinth/test/getProjects.test.ts b/packages/modrinth/test/getProjects.test.ts new file mode 100644 index 000000000..d2656336d --- /dev/null +++ b/packages/modrinth/test/getProjects.test.ts @@ -0,0 +1,43 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; +import { BadRequest } from "../src/errors.ts"; +import { getProjects } from "../src/operations/getProjects.ts"; +import { runEffect } from "./setup.ts"; + +// Long-lived public Modrinth project slugs used as stable read fixtures. +const STABLE_SLUGS = ["sodium", "fabric-api"]; + +describe("getProjects", () => { + it("returns multiple projects for a JSON-encoded array of slugs", async () => { + const result = await runEffect( + getProjects({ ids: JSON.stringify(STABLE_SLUGS) }), + ); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(STABLE_SLUGS.length); + for (const project of result) { + expect(typeof project.id).toBe("string"); + expect(project.id.length).toBeGreaterThan(0); + expect(typeof project.team).toBe("string"); + expect(typeof project.published).toBe("string"); + expect(typeof project.updated).toBe("string"); + expect(typeof project.followers).toBe("number"); + } + }); + + it("returns an empty array when given an empty id list", async () => { + const result = await runEffect(getProjects({ ids: "[]" })); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(0); + }); + + it("returns BadRequest when ids is not valid JSON", async () => { + const error = await runEffect( + getProjects({ ids: "this-is-not-valid-json" }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }); +}); diff --git a/packages/modrinth/test/getReport.test.ts b/packages/modrinth/test/getReport.test.ts new file mode 100644 index 000000000..ed7dd5ee6 --- /dev/null +++ b/packages/modrinth/test/getReport.test.ts @@ -0,0 +1,101 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { BadRequest, NotFound, Unauthorized } from "../src/errors.ts"; +import { getOpenReports } from "../src/operations/getOpenReports.ts"; +import { getReport } from "../src/operations/getReport.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("getReport", () => { + it.skipIf(!HAS_API_KEY)( + "fetches a single report belonging to the authenticated user", + async () => { + // GET /report/{id} requires auth and returns the matching report. + // We bootstrap by listing the auth'd user's open reports and using + // the first id; if the queue is empty we skip the round-trip + // assertion since there is nothing real to fetch — the listing + // request itself still exercises the auth path. + const openReports = await runEffect(getOpenReports({})); + const seed = openReports.find((r) => r.id !== undefined); + if (!seed || !seed.id) { + // No open reports to round-trip — nothing meaningful to assert. + return; + } + const reportId = seed.id; + + const result = await runEffect(getReport({ id: reportId })); + + expect(result.id).toBe(reportId); + expect(typeof result.report_type).toBe("string"); + expect(typeof result.item_id).toBe("string"); + expect(["project", "user", "version"]).toContain(result.item_type); + expect(typeof result.body).toBe("string"); + expect(typeof result.reporter).toBe("string"); + expect(typeof result.created).toBe("string"); + expect(typeof result.closed).toBe("boolean"); + expect(typeof result.thread_id).toBe("string"); + }, + ); + + it("returns BadRequest for an id that is not valid base62", async () => { + // Modrinth ids are base62-encoded; the path validator rejects ids + // containing non-base62 characters (e.g. `!`) with a + // `400 invalid_input` before any auth or DB lookup, so the typed + // BadRequest is reachable without an API key. This 400 is added by + // patches/001-add-error-responses.patch.json. + const error = await runEffect( + getReport({ id: `zz!${testRunId}` }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound for a base62-shaped id that does not exist", + async () => { + // With auth and a base62-shaped id Modrinth resolves the route, + // looks up the report, and returns 404 when nothing matches. + // We pad the testRunId so the path validator accepts it and the + // lookup actually fires. + const id = `zz${testRunId.slice(0, 6)}`; + const error = await runEffect( + getReport({ id }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // GET /report/{id} requires auth. With a base62-shaped id the path + // validator passes, the auth check fires next, and Modrinth returns + // 401 without an API key. + const id = `zz${testRunId.slice(0, 6)}`; + const error = await Effect.runPromise( + getReport({ id }).pipe(Effect.flip, Effect.provide(NoAuthLayer)), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/getReports.test.ts b/packages/modrinth/test/getReports.test.ts new file mode 100644 index 000000000..07db2d22f --- /dev/null +++ b/packages/modrinth/test/getReports.test.ts @@ -0,0 +1,104 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { BadRequest, NotFound, Unauthorized } from "../src/errors.ts"; +import { getOpenReports } from "../src/operations/getOpenReports.ts"; +import { getReports } from "../src/operations/getReports.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("getReports", () => { + it.skipIf(!HAS_API_KEY)( + "returns the requested reports for a list of valid ids", + async () => { + // Bootstrap: list the auth'd user's open reports and round-trip + // their ids through GET /reports?ids=[...]. If the queue is empty + // we cannot exercise the batch lookup and return early — the + // listing call still confirms auth. + const open = await runEffect(getOpenReports({})); + const ids = open + .map((r) => r.id) + .filter((id): id is string => typeof id === "string") + .slice(0, 2); + if (ids.length === 0) { + return; + } + + const reports = await runEffect( + getReports({ ids: JSON.stringify(ids) }), + ); + + expect(Array.isArray(reports)).toBe(true); + expect(reports.length).toBe(ids.length); + for (const id of ids) { + expect(reports.some((r) => r.id === id)).toBe(true); + } + }, + ); + + it.skipIf(!HAS_API_KEY)( + "returns BadRequest when the ids query is not valid JSON", + async () => { + // Modrinth parses `ids` as a JSON array; a non-JSON value yields a + // 400 invalid_input. This 400 mapping is added by + // patches/001-add-error-responses.patch.json. Auth runs before the + // query parse here, so we send an API key to reach the validator. + const error = await runEffect( + getReports({ ids: "not-json" }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }, + ); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound when the ids list contains a non-existent base62 id", + async () => { + // With auth and a syntactically-valid JSON array of base62-shaped + // ids that resolve to nothing, Modrinth's report lookup returns + // 404 (the spec lists NotFound for /reports). We pad the testRunId + // so each id is base62-shaped and the path-level validator passes. + const phantomId = `zz${testRunId.slice(0, 6)}`; + const error = await runEffect( + getReports({ ids: JSON.stringify([phantomId]) }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // GET /reports requires auth — reports are user-scoped. With a + // well-formed JSON ids query the auth check fires first and Modrinth + // returns 401 without an API key. + const phantomId = `zz${testRunId.slice(0, 6)}`; + const error = await Effect.runPromise( + getReports({ ids: JSON.stringify([phantomId]) }).pipe( + Effect.flip, + Effect.provide(NoAuthLayer), + ), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/getTeamMembers.test.ts b/packages/modrinth/test/getTeamMembers.test.ts new file mode 100644 index 000000000..f3f074e9b --- /dev/null +++ b/packages/modrinth/test/getTeamMembers.test.ts @@ -0,0 +1,46 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; +import { BadRequest } from "../src/errors.ts"; +import { getProjectTeamMembers } from "../src/operations/getProjectTeamMembers.ts"; +import { getTeamMembers } from "../src/operations/getTeamMembers.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +describe("getTeamMembers", () => { + it("returns the members of a team resolved from a public project", async () => { + // GET /team/{id}/members is a public read endpoint. Bootstrap via + // GET /project/sodium/members to obtain a stable, real team_id and + // round-trip it. Both routes return the same TeamMember shape so the + // membership lists for the same team_id should match. + const projectMembers = await runEffect( + getProjectTeamMembers({ id_or_slug: "sodium" }), + ); + expect(projectMembers.length).toBeGreaterThan(0); + const teamId = projectMembers[0]!.team_id; + + const teamMembers = await runEffect(getTeamMembers({ id: teamId })); + + expect(Array.isArray(teamMembers)).toBe(true); + expect(teamMembers.length).toBeGreaterThan(0); + for (const member of teamMembers) { + expect(member.team_id).toBe(teamId); + expect(typeof member.role).toBe("string"); + expect(typeof member.accepted).toBe("boolean"); + expect(typeof member.user.id).toBe("string"); + expect(typeof member.user.username).toBe("string"); + expect(["admin", "moderator", "developer"]).toContain(member.user.role); + } + }); + + it("returns BadRequest for an id that is not valid base62", async () => { + // Modrinth team ids are base62-encoded; the path validator rejects + // ids containing non-base62 characters (e.g. `!`) with a + // `400 invalid_input` before any DB lookup. This 400 mapping is + // added by patches/001-add-error-responses.patch.json. + const error = await runEffect( + getTeamMembers({ id: `zz!${testRunId}` }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }); +}); diff --git a/packages/modrinth/test/getTeams.test.ts b/packages/modrinth/test/getTeams.test.ts new file mode 100644 index 000000000..fcaa641b8 --- /dev/null +++ b/packages/modrinth/test/getTeams.test.ts @@ -0,0 +1,51 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; +import { BadRequest } from "../src/errors.ts"; +import { getProjectTeamMembers } from "../src/operations/getProjectTeamMembers.ts"; +import { getTeams } from "../src/operations/getTeams.ts"; +import { runEffect } from "./setup.ts"; + +describe("getTeams", () => { + it("returns the members of the requested teams for a list of valid ids", async () => { + // Bootstrap via GET /project/sodium/members to obtain a stable, real + // team_id and round-trip it through GET /teams?ids=[...]. The route + // does not require auth and returns one inner array per requested + // team — for a single id we expect exactly one inner array whose + // members all carry the same team_id. + const sodiumMembers = await runEffect( + getProjectTeamMembers({ id_or_slug: "sodium" }), + ); + expect(sodiumMembers.length).toBeGreaterThan(0); + const teamId = sodiumMembers[0]!.team_id; + + const teams = await runEffect( + getTeams({ ids: JSON.stringify([teamId]) }), + ); + + expect(Array.isArray(teams)).toBe(true); + expect(teams.length).toBe(1); + const members = teams[0]!; + expect(Array.isArray(members)).toBe(true); + expect(members.length).toBeGreaterThan(0); + for (const member of members) { + expect(member.team_id).toBe(teamId); + expect(typeof member.role).toBe("string"); + expect(typeof member.accepted).toBe("boolean"); + expect(typeof member.user.id).toBe("string"); + expect(typeof member.user.username).toBe("string"); + expect(["admin", "moderator", "developer"]).toContain(member.user.role); + } + }); + + it("returns BadRequest when the ids query is not valid JSON", async () => { + // Modrinth parses `ids` as a JSON array; a non-JSON value yields a + // 400 invalid_input. This 400 mapping is added by + // patches/001-add-error-responses.patch.json. + const error = await runEffect( + getTeams({ ids: "not-json" }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }); +}); diff --git a/packages/modrinth/test/getThread.test.ts b/packages/modrinth/test/getThread.test.ts new file mode 100644 index 000000000..534e7c9ea --- /dev/null +++ b/packages/modrinth/test/getThread.test.ts @@ -0,0 +1,95 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { BadRequest, NotFound, Unauthorized } from "../src/errors.ts"; +import { getOpenReports } from "../src/operations/getOpenReports.ts"; +import { getThread } from "../src/operations/getThread.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("getThread", () => { + it.skipIf(!HAS_API_KEY)( + "fetches a thread reachable from the authenticated user's open reports", + async () => { + // GET /thread/{id} requires auth and returns the matching thread. + // We bootstrap by listing the auth'd user's open reports and + // round-tripping the first report's thread_id; if the queue is + // empty we cannot exercise the lookup and return early — the + // listing call still confirms auth. + const open = await runEffect(getOpenReports({})); + const seed = open.find((r) => typeof r.thread_id === "string"); + if (!seed) { + return; + } + const threadId = seed.thread_id; + + const result = await runEffect(getThread({ id: threadId })); + + expect(result.id).toBe(threadId); + expect(["project", "report", "direct_message"]).toContain(result.type); + expect(Array.isArray(result.messages)).toBe(true); + expect(Array.isArray(result.members)).toBe(true); + }, + ); + + it("returns BadRequest for an id that is not valid base62", async () => { + // Modrinth ids are base62-encoded; the path validator rejects ids + // containing non-base62 characters (e.g. `!`) with a + // `400 invalid_input` before any auth or DB lookup, so the typed + // BadRequest is reachable without an API key. This 400 mapping is + // added by patches/001-add-error-responses.patch.json. + const error = await runEffect( + getThread({ id: `zz!${testRunId}` }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound for a base62-shaped id that does not exist", + async () => { + // With auth and a base62-shaped id Modrinth resolves the route, + // looks up the thread, and returns 404 when nothing matches. We + // pad the testRunId so the path validator accepts it and the + // lookup actually fires. + const id = `zz${testRunId.slice(0, 6)}`; + const error = await runEffect( + getThread({ id }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // GET /thread/{id} requires auth. With a base62-shaped id the path + // validator passes, the auth check fires next, and Modrinth returns + // 401 without an API key. + const id = `zz${testRunId.slice(0, 6)}`; + const error = await Effect.runPromise( + getThread({ id }).pipe(Effect.flip, Effect.provide(NoAuthLayer)), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/getThreads.test.ts b/packages/modrinth/test/getThreads.test.ts new file mode 100644 index 000000000..99c6080ff --- /dev/null +++ b/packages/modrinth/test/getThreads.test.ts @@ -0,0 +1,108 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { BadRequest, NotFound, Unauthorized } from "../src/errors.ts"; +import { getOpenReports } from "../src/operations/getOpenReports.ts"; +import { getThreads } from "../src/operations/getThreads.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("getThreads", () => { + it.skipIf(!HAS_API_KEY)( + "returns the requested threads for a list of valid ids", + async () => { + // Bootstrap: list the auth'd user's open reports and round-trip + // their thread_ids through GET /threads?ids=[...]. If the queue + // is empty we cannot exercise the batch lookup and return early — + // the listing call still confirms auth. + const open = await runEffect(getOpenReports({})); + const ids = open + .map((r) => r.thread_id) + .filter((id): id is string => typeof id === "string") + .slice(0, 2); + if (ids.length === 0) { + return; + } + + const threads = await runEffect( + getThreads({ ids: JSON.stringify(ids) }), + ); + + expect(Array.isArray(threads)).toBe(true); + expect(threads.length).toBe(ids.length); + for (const id of ids) { + const match = threads.find((t) => t.id === id); + expect(match).toBeDefined(); + expect(["project", "report", "direct_message"]).toContain(match!.type); + expect(Array.isArray(match!.messages)).toBe(true); + expect(Array.isArray(match!.members)).toBe(true); + } + }, + ); + + it.skipIf(!HAS_API_KEY)( + "returns BadRequest when the ids query is not valid JSON", + async () => { + // Modrinth parses `ids` as a JSON array; a non-JSON value yields a + // 400 invalid_input. This 400 mapping is added by + // patches/002-add-mutation-bad-request.patch.json. Auth runs before + // the query parse here, so we send an API key to reach the validator. + const error = await runEffect( + getThreads({ ids: "not-json" }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }, + ); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound when the ids list contains a non-existent base62 id", + async () => { + // With auth and a syntactically-valid JSON array of base62-shaped + // ids that resolve to nothing, Modrinth's thread lookup returns + // 404 (the spec lists NotFound for /threads). We pad the testRunId + // so each id is base62-shaped and the path-level validator passes. + const phantomId = `zz${testRunId.slice(0, 6)}`; + const error = await runEffect( + getThreads({ ids: JSON.stringify([phantomId]) }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // GET /threads requires auth — threads are user-scoped. With a + // well-formed JSON ids query the auth check fires first and Modrinth + // returns 401 without an API key. + const phantomId = `zz${testRunId.slice(0, 6)}`; + const error = await Effect.runPromise( + getThreads({ ids: JSON.stringify([phantomId]) }).pipe( + Effect.flip, + Effect.provide(NoAuthLayer), + ), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/getUser.test.ts b/packages/modrinth/test/getUser.test.ts new file mode 100644 index 000000000..45f9e7bad --- /dev/null +++ b/packages/modrinth/test/getUser.test.ts @@ -0,0 +1,48 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; +import { NotFound } from "../src/errors.ts"; +import { getUser } from "../src/operations/getUser.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +describe("getUser", () => { + it("resolves a real user by username", async () => { + // GET /user/{id_or_username} accepts either the user's id or username + // and returns their public profile. We hit jellysquid3 — sodium's + // author — because the account is well-known, public, and stable. + const result = await runEffect( + getUser({ id_or_username: "jellysquid3" }), + ); + + expect(result.username).toBe("jellysquid3"); + expect(typeof result.id).toBe("string"); + expect(result.id.length).toBeGreaterThan(0); + expect(result.role).toBe("developer"); + expect(typeof result.avatar_url).toBe("string"); + expect(typeof result.created).toBe("string"); + }); + + it("resolves the same user when looked up by id", async () => { + // The route is symmetric on id and username: looking the user up by id + // must return the same profile. + const byUsername = await runEffect( + getUser({ id_or_username: "jellysquid3" }), + ); + const byId = await runEffect(getUser({ id_or_username: byUsername.id })); + + expect(byId.id).toBe(byUsername.id); + expect(byId.username).toBe(byUsername.username); + }); + + it("returns NotFound for a username that does not exist", async () => { + // Modrinth usernames are alphanumeric and case-insensitive. A username + // prefixed with "zz-distilled-" plus testRunId is guaranteed not to + // collide with any real account, so the route returns 404. + const username = `zz-distilled-${testRunId}`; + const error = await runEffect( + getUser({ id_or_username: username }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }); +}); diff --git a/packages/modrinth/test/getUserFromAuth.test.ts b/packages/modrinth/test/getUserFromAuth.test.ts new file mode 100644 index 000000000..cafbbb033 --- /dev/null +++ b/packages/modrinth/test/getUserFromAuth.test.ts @@ -0,0 +1,64 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { Unauthorized } from "../src/errors.ts"; +import { getUserFromAuth } from "../src/operations/getUserFromAuth.ts"; +import { runEffect } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("getUserFromAuth", () => { + it.skipIf(!HAS_API_KEY)( + "resolves the user owning the configured API key", + async () => { + // GET /user reads the Authorization header and returns the matching + // user profile. With a valid API key Modrinth returns the same shape + // as GET /user/{id_or_username}. + const result = await runEffect(getUserFromAuth({})); + + expect(typeof result.id).toBe("string"); + expect(result.id.length).toBeGreaterThan(0); + expect(typeof result.username).toBe("string"); + expect(result.username.length).toBeGreaterThan(0); + expect(typeof result.avatar_url).toBe("string"); + expect(typeof result.created).toBe("string"); + expect(["admin", "moderator", "developer"]).toContain(result.role); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // GET /user is purely auth-driven: there is no path or query param to + // resolve, so removing the API key sends Modrinth straight into the + // auth check, which responds 401. + const error = await Effect.runPromise( + getUserFromAuth({}).pipe(Effect.flip, Effect.provide(NoAuthLayer)), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); + + // NotFound note: + // GET /user takes no path or query parameters — it identifies the user + // purely from the Authorization header. There is no resource id the + // caller can supply that would miss, so Modrinth's response set for + // this route is `200` (valid token) or `401` (missing/invalid token). + // 404 is unreachable through this operation and therefore cannot be + // exercised here. +}); diff --git a/packages/modrinth/test/getUserNotifications.test.ts b/packages/modrinth/test/getUserNotifications.test.ts new file mode 100644 index 000000000..3669ef50c --- /dev/null +++ b/packages/modrinth/test/getUserNotifications.test.ts @@ -0,0 +1,84 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { NotFound, Unauthorized } from "../src/errors.ts"; +import { getUserNotifications } from "../src/operations/getUserNotifications.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +const OWNED_USER_ID = process.env.MODRINTH_TEST_OWNED_USER_ID; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("getUserNotifications", () => { + it.skipIf(!OWNED_USER_ID)( + "returns the notifications of the authenticated user", + async () => { + // GET /user/{id_or_username}/notifications requires auth and returns + // the auth'd user's inbox. The list may legitimately be empty for + // accounts with no pending notifications, so we only assert on + // shape/types. + const id = OWNED_USER_ID as string; + const notifications = await runEffect( + getUserNotifications({ id_or_username: id }), + ); + + expect(Array.isArray(notifications)).toBe(true); + for (const n of notifications) { + expect(typeof n.id).toBe("string"); + expect(typeof n.user_id).toBe("string"); + expect(typeof n.title).toBe("string"); + expect(typeof n.text).toBe("string"); + expect(typeof n.link).toBe("string"); + expect(typeof n.read).toBe("boolean"); + expect(typeof n.created).toBe("string"); + expect(Array.isArray(n.actions)).toBe(true); + } + }, + ); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound for a username that does not exist", + async () => { + // With auth Modrinth resolves the route, looks up the user, and + // returns 404 when the username is unknown. + const username = `zz-distilled-${testRunId}`; + const error = await runEffect( + getUserNotifications({ id_or_username: username }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // GET /user/{id_or_username}/notifications requires auth. Modrinth + // runs the auth check before any user lookup, so any well-formed + // username (including the public `jellysquid3` account) yields 401 + // with no API key. + const error = await Effect.runPromise( + getUserNotifications({ id_or_username: "jellysquid3" }).pipe( + Effect.flip, + Effect.provide(NoAuthLayer), + ), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/getUserProjects.test.ts b/packages/modrinth/test/getUserProjects.test.ts new file mode 100644 index 000000000..2c7bd48b7 --- /dev/null +++ b/packages/modrinth/test/getUserProjects.test.ts @@ -0,0 +1,44 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; +import { NotFound } from "../src/errors.ts"; +import { getUserProjects } from "../src/operations/getUserProjects.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +describe("getUserProjects", () => { + it("returns the projects owned by a real user", async () => { + // GET /user/{id_or_username}/projects returns the public list of + // projects the user is a member of. We hit jellysquid3 — sodium's + // author — because the account is well-known, public, and ships + // multiple maintained projects, so we can confidently assert the + // sodium project appears in the response. + const projects = await runEffect( + getUserProjects({ id_or_username: "jellysquid3" }), + ); + + expect(Array.isArray(projects)).toBe(true); + expect(projects.length).toBeGreaterThan(0); + for (const project of projects) { + expect(typeof project.id).toBe("string"); + expect(project.id.length).toBeGreaterThan(0); + expect(typeof project.team).toBe("string"); + expect(typeof project.published).toBe("string"); + expect(typeof project.updated).toBe("string"); + } + // sodium has the slug `AANobbMI` — verify the canonical project for + // jellysquid3 is present. + expect(projects.some((p) => p.id === "AANobbMI")).toBe(true); + }); + + it("returns NotFound for a username that does not exist", async () => { + // Modrinth usernames are alphanumeric and case-insensitive. A username + // prefixed with "zz-distilled-" plus testRunId is guaranteed not to + // collide with any real account, so the route returns 404. + const username = `zz-distilled-${testRunId}`; + const error = await runEffect( + getUserProjects({ id_or_username: username }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }); +}); diff --git a/packages/modrinth/test/getUsers.test.ts b/packages/modrinth/test/getUsers.test.ts new file mode 100644 index 000000000..c18afbf7c --- /dev/null +++ b/packages/modrinth/test/getUsers.test.ts @@ -0,0 +1,38 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; +import { BadRequest } from "../src/errors.ts"; +import { getUser } from "../src/operations/getUser.ts"; +import { getUsers } from "../src/operations/getUsers.ts"; +import { runEffect } from "./setup.ts"; + +describe("getUsers", () => { + it("returns the requested users for a list of valid ids", async () => { + // GET /users?ids=[...] reads `ids` as a JSON-encoded array and returns + // the matching user profiles. We resolve jellysquid3 by username first + // to get a real id, then round-trip it through the bulk route. + const jelly = await runEffect(getUser({ id_or_username: "jellysquid3" })); + expect(typeof jelly.id).toBe("string"); + const ids = [jelly.id]; + + const users = await runEffect(getUsers({ ids: JSON.stringify(ids) })); + + expect(Array.isArray(users)).toBe(true); + expect(users.length).toBe(1); + expect(users[0]!.id).toBe(jelly.id); + expect(users[0]!.username).toBe("jellysquid3"); + expect(["admin", "moderator", "developer"]).toContain(users[0]!.role); + }); + + it("returns BadRequest when the ids query is not valid JSON", async () => { + // Modrinth parses `ids` as a JSON array; a non-JSON value yields a 400 + // invalid_input. The SDK schema accepts any string for `ids`, so this + // request actually leaves the client and the typed BadRequest comes + // from the server. + const error = await runEffect( + getUsers({ ids: "not-json" }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }); +}); diff --git a/packages/modrinth/test/getVersion.test.ts b/packages/modrinth/test/getVersion.test.ts new file mode 100644 index 000000000..1e8d6446e --- /dev/null +++ b/packages/modrinth/test/getVersion.test.ts @@ -0,0 +1,50 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; +import { BadRequest, NotFound } from "../src/errors.ts"; +import { getProjectVersions } from "../src/operations/getProjectVersions.ts"; +import { getVersion } from "../src/operations/getVersion.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +describe("getVersion", () => { + it("returns the version metadata for a known stable version", async () => { + // Fetch the current version list for `sodium` and use the first id, since + // Modrinth's per-version ids are stable identifiers but the latest one + // changes over time as new releases are published. + const versions = await runEffect( + getProjectVersions({ + id_or_slug: "sodium", + include_changelog: false, + }), + ); + expect(versions.length).toBeGreaterThan(0); + const id = versions[0]!.id; + + const version = await runEffect(getVersion({ id })); + + expect(version.id).toBe(id); + expect(typeof version.project_id).toBe("string"); + expect(typeof version.version_number).toBe("string"); + expect(Array.isArray(version.files)).toBe(true); + }); + + it("returns BadRequest for a malformed version id", async () => { + // Modrinth version ids are 8-character base62 strings. A value with + // hyphens fails the format validation and yields a 400 invalid_input. + const error = await runEffect( + getVersion({ id: "not-a-valid-id" }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }); + + it("returns NotFound for a version id that does not exist", async () => { + // An 8-character base62 id with the testRunId baked in is well-formed + // but guaranteed not to exist; Modrinth returns 404 for it. + const id = `zz${testRunId.slice(0, 6)}`; + const error = await runEffect(getVersion({ id }).pipe(Effect.flip)); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }); +}); diff --git a/packages/modrinth/test/getVersionFromIdOrNumber.test.ts b/packages/modrinth/test/getVersionFromIdOrNumber.test.ts new file mode 100644 index 000000000..8e1ab5ada --- /dev/null +++ b/packages/modrinth/test/getVersionFromIdOrNumber.test.ts @@ -0,0 +1,67 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; +import { NotFound } from "../src/errors.ts"; +import { getProjectVersions } from "../src/operations/getProjectVersions.ts"; +import { getVersionFromIdOrNumber } from "../src/operations/getVersionFromIdOrNumber.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +describe("getVersionFromIdOrNumber", () => { + it("resolves a version by version id under a project slug", async () => { + // Fetch a current version id from `sodium` and round-trip it through + // /project/sodium/version/{id}. Modrinth resolves either a version id + // or a version_number for the second path segment. + const versions = await runEffect( + getProjectVersions({ + id_or_slug: "sodium", + include_changelog: false, + }), + ); + expect(versions.length).toBeGreaterThan(0); + const id = versions[0]!.id; + + const version = await runEffect( + getVersionFromIdOrNumber({ id_or_slug: "sodium", id_or_number: id }), + ); + + expect(version.id).toBe(id); + expect(typeof version.project_id).toBe("string"); + expect(typeof version.version_number).toBe("string"); + }); + + it("resolves a version by version number under a project slug", async () => { + // The same route accepts a version_number; Modrinth returns the oldest + // matching version when the number maps to multiple versions. + const versions = await runEffect( + getProjectVersions({ + id_or_slug: "sodium", + include_changelog: false, + }), + ); + expect(versions.length).toBeGreaterThan(0); + const versionNumber = versions[0]!.version_number; + + const version = await runEffect( + getVersionFromIdOrNumber({ + id_or_slug: "sodium", + id_or_number: versionNumber, + }), + ); + + expect(version.version_number).toBe(versionNumber); + expect(typeof version.id).toBe("string"); + }); + + it("returns NotFound for a version that does not exist on the project", async () => { + // A run-id-suffixed version_number is guaranteed not to exist on + // sodium's release list and the route returns 404 for unknown values. + const error = await runEffect( + getVersionFromIdOrNumber({ + id_or_slug: "sodium", + id_or_number: `distilled-mr-missing-${testRunId}`, + }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }); +}); diff --git a/packages/modrinth/test/getVersions.test.ts b/packages/modrinth/test/getVersions.test.ts new file mode 100644 index 000000000..15d1b64f7 --- /dev/null +++ b/packages/modrinth/test/getVersions.test.ts @@ -0,0 +1,43 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; +import { BadRequest } from "../src/errors.ts"; +import { getProjectVersions } from "../src/operations/getProjectVersions.ts"; +import { getVersions } from "../src/operations/getVersions.ts"; +import { runEffect } from "./setup.ts"; + +describe("getVersions", () => { + it("returns the requested versions for a list of valid ids", async () => { + // Fetch the latest sodium version ids and round-trip the first two + // through GET /versions?ids=[...]. The route does not require auth and + // returns the matching version objects. + const sodiumVersions = await runEffect( + getProjectVersions({ + id_or_slug: "sodium", + include_changelog: false, + }), + ); + expect(sodiumVersions.length).toBeGreaterThan(0); + const ids = sodiumVersions.slice(0, 2).map((v) => v.id); + + const versions = await runEffect( + getVersions({ ids: JSON.stringify(ids) }), + ); + + expect(Array.isArray(versions)).toBe(true); + expect(versions.length).toBe(ids.length); + for (const id of ids) { + expect(versions.some((v) => v.id === id)).toBe(true); + } + }); + + it("returns BadRequest when the ids query is not valid JSON", async () => { + // Modrinth parses `ids` as a JSON array; a non-JSON value yields a 400 + // invalid_input. + const error = await runEffect( + getVersions({ ids: "not-json" }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }); +}); diff --git a/packages/modrinth/test/joinTeam.test.ts b/packages/modrinth/test/joinTeam.test.ts new file mode 100644 index 000000000..01e9e5847 --- /dev/null +++ b/packages/modrinth/test/joinTeam.test.ts @@ -0,0 +1,111 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { BadRequest, NotFound, Unauthorized } from "../src/errors.ts"; +import { deleteTeamMember } from "../src/operations/deleteTeamMember.ts"; +import { joinTeam } from "../src/operations/joinTeam.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +// POST /team/{id}/join accepts a pending team invitation for the +// auth'd user. The happy path is quadruple-gated: API key, a team id +// that the auth'd user has a pending invitation to (via +// MODRINTH_TEST_JOIN_TEAM_ID), the auth'd user's id (via +// MODRINTH_TEST_OWNED_USER_ID, used to leave the team for cleanup), +// and an explicit opt-in flag MODRINTH_TEST_ALLOW_JOIN_TEAM=1. +const JOIN_TEAM_ID = process.env.MODRINTH_TEST_JOIN_TEAM_ID; +const OWNED_USER_ID = process.env.MODRINTH_TEST_OWNED_USER_ID; +const ALLOW_JOIN = process.env.MODRINTH_TEST_ALLOW_JOIN_TEAM === "1"; +const SHOULD_RUN_HAPPY = + HAS_API_KEY && !!JOIN_TEAM_ID && !!OWNED_USER_ID && ALLOW_JOIN; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("joinTeam", () => { + it.skipIf(!SHOULD_RUN_HAPPY)( + "accepts a pending team invitation for the authenticated user", + async () => { + // Real team membership is observable to other team members. The + // SDK call returns 204/Void on success; we assert that and rely + // on ensuring-style cleanup to leave the team afterwards (via + // DELETE /team/{id}/members/{user_id} with the auth'd user's id) + // so subsequent runs can re-invite and re-join. + const teamId = JOIN_TEAM_ID as string; + const userId = OWNED_USER_ID as string; + + const result = await runEffect( + joinTeam({ id: teamId }).pipe( + Effect.ensuring( + // Always leave the team after the test completes, even on + // failure. Effect.ignore swallows any failure of the delete + // call itself so cleanup never masks the real result. + deleteTeamMember({ + id: teamId, + id_or_username: userId, + }).pipe(Effect.ignore), + ), + ), + ); + + expect(result).toBeUndefined(); + }, + 30_000, + ); + + it("returns BadRequest for an id that is not valid base62", async () => { + // Modrinth team ids are base62-encoded; the path validator rejects + // ids containing non-base62 characters (e.g. `!`) with a + // `400 invalid_input` before any auth or DB lookup, so the typed + // BadRequest is reachable without an API key. This 400 mapping is + // added by patches/002-add-mutation-bad-request.patch.json. + const error = await runEffect( + joinTeam({ id: `zz!${testRunId}` }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound for a base62-shaped team id that does not exist", + async () => { + // With auth and a base62-shaped team id Modrinth resolves the + // route, looks up the team, and returns 404 when nothing matches. + // We pad the testRunId so the path validator accepts it and the + // lookup actually fires. + const id = `zz${testRunId.slice(0, 6)}`; + const error = await runEffect(joinTeam({ id }).pipe(Effect.flip)); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // POST /team/{id}/join requires auth — only the invited user may + // accept their own invitation. With a base62-shaped id the path + // validator passes, the auth check fires next, and Modrinth returns + // 401 without an API key. + const id = `zz${testRunId.slice(0, 6)}`; + const error = await Effect.runPromise( + joinTeam({ id }).pipe(Effect.flip, Effect.provide(NoAuthLayer)), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/licenseText.test.ts b/packages/modrinth/test/licenseText.test.ts new file mode 100644 index 000000000..4a3174510 --- /dev/null +++ b/packages/modrinth/test/licenseText.test.ts @@ -0,0 +1,40 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; +import { BadRequest } from "../src/errors.ts"; +import { licenseText } from "../src/operations/licenseText.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +describe("licenseText", () => { + it("returns the title and body for a well-known SPDX license id", async () => { + // GET /tag/license/{id} is a public read endpoint. The MIT license + // is one of the canonical SPDX identifiers Modrinth ships with; + // both `title` and `body` are documented as optional but in + // practice are populated for recognized licenses. + const license = await runEffect(licenseText({ id: "mit" })); + + if (license.title !== undefined) { + expect(typeof license.title).toBe("string"); + expect(license.title.length).toBeGreaterThan(0); + } + if (license.body !== undefined) { + expect(typeof license.body).toBe("string"); + expect(license.body.length).toBeGreaterThan(0); + } + // At least one of the two fields must be populated for a real + // recognized license. + expect(license.title !== undefined || license.body !== undefined).toBe( + true, + ); + }); + + it("returns BadRequest for a license id that is not recognized", async () => { + // Modrinth validates the license id against its known SPDX list and + // returns `400 invalid_input` for ids it does not recognize. A + // run-scoped id guarantees the lookup misses. + const id = `distilled-no-such-license-${testRunId}`; + const error = await runEffect(licenseText({ id }).pipe(Effect.flip)); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }); +}); diff --git a/packages/modrinth/test/loaderList.test.ts b/packages/modrinth/test/loaderList.test.ts new file mode 100644 index 000000000..8959deafc --- /dev/null +++ b/packages/modrinth/test/loaderList.test.ts @@ -0,0 +1,56 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { Credentials, DEFAULT_USER_AGENT } from "../src/credentials.ts"; +import { NotFound } from "../src/errors.ts"; +import { loaderList } from "../src/operations/loaderList.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +// Layer that points the SDK at a non-existent path on the real Modrinth +// host. loaderList takes no parameters, so the only way to exercise a +// SDK-mapped error path is to redirect the base URL to a route that +// 404s. This proves the operation's status-code → typed-error mapping +// works on a parameterless GET. +const BogusBaseUrlLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: `https://api.modrinth.com/v2-nonexistent-${testRunId}`, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("loaderList", () => { + it("returns the public list of loaders", async () => { + // GET /tag/loader is a public, parameterless read endpoint. Every + // Modrinth deployment ships with at least the core loaders (forge, + // fabric, etc.), so we assert the array shape and that each entry + // carries the documented fields. + const loaders = await runEffect(loaderList({})); + + expect(Array.isArray(loaders)).toBe(true); + expect(loaders.length).toBeGreaterThan(0); + for (const loader of loaders) { + expect(typeof loader.icon).toBe("string"); + expect(typeof loader.name).toBe("string"); + expect(Array.isArray(loader.supported_project_types)).toBe(true); + for (const projectType of loader.supported_project_types) { + expect(typeof projectType).toBe("string"); + } + } + }); + + it("returns NotFound when the base URL points to a non-existent path", async () => { + // loaderList has no input parameters, so the only deterministic + // way to provoke a typed error from the SDK is to override the + // base URL to a path that doesn't exist. Modrinth answers any + // unknown route with `404 not_found`, which the SDK maps to the + // typed `NotFound`. + const error = await Effect.runPromise( + loaderList({}).pipe(Effect.flip, Effect.provide(BogusBaseUrlLayer)), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }); +}); diff --git a/packages/modrinth/test/modifyGalleryImage.test.ts b/packages/modrinth/test/modifyGalleryImage.test.ts new file mode 100644 index 000000000..9e088c222 --- /dev/null +++ b/packages/modrinth/test/modifyGalleryImage.test.ts @@ -0,0 +1,79 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { NotFound, Unauthorized } from "../src/errors.ts"; +import { modifyGalleryImage } from "../src/operations/modifyGalleryImage.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +const OWNED_PROJECT_ID = process.env.MODRINTH_TEST_OWNED_PROJECT_ID; +const OWNED_GALLERY_URL = process.env.MODRINTH_TEST_OWNED_GALLERY_URL; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("modifyGalleryImage", () => { + it.skipIf(!OWNED_PROJECT_ID || !OWNED_GALLERY_URL)( + "modifies an existing gallery image on a project the caller owns", + async () => { + // PATCH /project/{slug}/gallery?url=...&title=... rewrites the metadata + // for the gallery image identified by url. Owners get 204. + const projectId = OWNED_PROJECT_ID as string; + const url = OWNED_GALLERY_URL as string; + await runEffect( + modifyGalleryImage({ + id_or_slug: projectId, + url, + featured: false, + title: `distilled-mr-gallery-${testRunId}`, + description: `distilled gallery patch ${testRunId}`, + ordering: 0, + }), + ); + }, + ); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound for a project slug that does not exist", + async () => { + // With auth, Modrinth resolves the project slug first; an unknown slug + // produces a 404 from the gallery PATCH route. + const error = await runEffect( + modifyGalleryImage({ + id_or_slug: `distilled-mr-missing-${testRunId}`, + url: `https://cdn-raw.modrinth.com/data/missing-${testRunId}/gallery/missing.png`, + }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // PATCH /project/{slug}/gallery requires auth. With url as a valid query + // param Modrinth reaches the auth check and returns 401. + const error = await Effect.runPromise( + modifyGalleryImage({ + id_or_slug: "sodium", + url: `https://cdn-raw.modrinth.com/data/sodium/gallery/example-${testRunId}.png`, + }).pipe(Effect.flip, Effect.provide(NoAuthLayer)), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/modifyProject.test.ts b/packages/modrinth/test/modifyProject.test.ts new file mode 100644 index 000000000..6c1587dc7 --- /dev/null +++ b/packages/modrinth/test/modifyProject.test.ts @@ -0,0 +1,66 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { NotFound, Unauthorized } from "../src/errors.ts"; +import { modifyProject } from "../src/operations/modifyProject.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +const TEST_PROJECT_ID = process.env.MODRINTH_TEST_PROJECT_ID; + +// Deterministic no-auth layer so the Unauthorized test triggers 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("modifyProject", () => { + it.skipIf(!TEST_PROJECT_ID)( + "performs a no-op patch on a project the caller owns", + async () => { + const projectId = TEST_PROJECT_ID as string; + // Empty PATCH body should succeed (204 No Content) for project owners. + // Output schema is Schema.Void, so success means no thrown error. + await runEffect(modifyProject({ id_or_slug: projectId })); + }, + ); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound for a non-existent project slug", + async () => { + const error = await runEffect( + modifyProject({ + id_or_slug: `distilled-mr-missing-${testRunId}`, + }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // Modrinth validates body shape BEFORE auth, so we must send a non-empty + // body with a recognised field (`moderation_message`) for the server to + // get to the auth check and respond 401. + const error = await Effect.runPromise( + modifyProject({ + id_or_slug: "sodium", + moderation_message: "distilled-test", + }).pipe(Effect.flip, Effect.provide(NoAuthLayer)), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/modifyReport.test.ts b/packages/modrinth/test/modifyReport.test.ts new file mode 100644 index 000000000..e0b7cc82b --- /dev/null +++ b/packages/modrinth/test/modifyReport.test.ts @@ -0,0 +1,115 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { BadRequest, NotFound, Unauthorized } from "../src/errors.ts"; +import { getOpenReports } from "../src/operations/getOpenReports.ts"; +import { getReport } from "../src/operations/getReport.ts"; +import { modifyReport } from "../src/operations/modifyReport.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +// PATCH /report/{id} mutates a real moderation report. Even though the +// reporter is allowed to edit the body of their own report, we keep the +// happy path opt-in (matches the submitReport.test.ts gating) so CI runs +// without the env var don't poke at live moderation state. +const ALLOW_MODIFY = process.env.MODRINTH_TEST_ALLOW_MODIFY_REPORT === "1"; +const SHOULD_RUN_HAPPY = HAS_API_KEY && ALLOW_MODIFY; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("modifyReport", () => { + it.skipIf(!SHOULD_RUN_HAPPY)( + "updates the body of an existing report and restores it on cleanup", + async () => { + // Bootstrap: list the auth'd user's open reports. If the user has + // no open reports we cannot exercise the happy path round-trip and + // the test returns early — the listing call still confirms auth. + const open = await runEffect(getOpenReports({})); + const seed = open.find((r) => r.id !== undefined && !r.closed); + if (!seed || !seed.id) { + return; + } + const reportId = seed.id; + const originalBody = seed.body; + const newBody = `${originalBody}\n\n[distilled SDK modifyReport probe — run ${testRunId}]`; + + await runEffect( + modifyReport({ id: reportId, body: newBody }).pipe( + Effect.ensuring( + // Always restore the original body, even if the assertion below + // throws. Effect.ignore swallows any failure of the restore call + // itself so cleanup never masks the real test result. + modifyReport({ id: reportId, body: originalBody }).pipe( + Effect.ignore, + ), + ), + ), + ); + + // PATCH returns 204/Void — verify the mutation took effect via GET. + const after = await runEffect(getReport({ id: reportId })); + expect(after.body).toBe(newBody); + }, + 30_000, + ); + + it("returns BadRequest for an id that is not valid base62", async () => { + // Modrinth ids are base62-encoded; the path validator rejects ids + // containing non-base62 characters (e.g. `!`) with a + // `400 invalid_input` before any auth or DB lookup, so the typed + // BadRequest is reachable without an API key. + const error = await runEffect( + modifyReport({ id: `zz!${testRunId}`, body: "noop" }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound for a base62-shaped id that does not exist", + async () => { + // With auth and a base62-shaped id Modrinth resolves the route, + // looks up the report, and returns 404 when nothing matches. We + // pad the testRunId so the path validator accepts it and the + // lookup actually fires. + const id = `zz${testRunId.slice(0, 6)}`; + const error = await runEffect( + modifyReport({ id, body: "noop" }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // PATCH /report/{id} requires auth. With a base62-shaped id the path + // validator passes, the auth check fires next, and Modrinth returns + // 401 without an API key. + const id = `zz${testRunId.slice(0, 6)}`; + const error = await Effect.runPromise( + modifyReport({ id, body: "noop" }).pipe( + Effect.flip, + Effect.provide(NoAuthLayer), + ), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/modifyTeamMember.test.ts b/packages/modrinth/test/modifyTeamMember.test.ts new file mode 100644 index 000000000..7d1e10089 --- /dev/null +++ b/packages/modrinth/test/modifyTeamMember.test.ts @@ -0,0 +1,144 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { BadRequest, NotFound, Unauthorized } from "../src/errors.ts"; +import { getTeamMembers } from "../src/operations/getTeamMembers.ts"; +import { modifyTeamMember } from "../src/operations/modifyTeamMember.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +// PATCH /team/{id}/members/{id_or_username} mutates a real team +// member's metadata (ordering, role, permissions, payouts_split). The +// happy path is quadruple-gated: API key, a team id the auth'd caller +// administers (MODRINTH_TEST_TEAM_ID), the member to bump +// (MODRINTH_TEST_TEAM_MEMBER_USERNAME), and an explicit opt-in flag +// MODRINTH_TEST_ALLOW_MODIFY_TEAM_MEMBER=1. We mutate `ordering` only +// (least disruptive) and restore the prior value via ensuring-style +// cleanup. +const TEAM_ID = process.env.MODRINTH_TEST_TEAM_ID; +const TARGET_MEMBER = process.env.MODRINTH_TEST_TEAM_MEMBER_USERNAME; +const ALLOW_MODIFY = + process.env.MODRINTH_TEST_ALLOW_MODIFY_TEAM_MEMBER === "1"; +const SHOULD_RUN_HAPPY = + HAS_API_KEY && !!TEAM_ID && !!TARGET_MEMBER && ALLOW_MODIFY; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("modifyTeamMember", () => { + it.skipIf(!SHOULD_RUN_HAPPY)( + "bumps a team member's ordering and restores it on cleanup", + async () => { + const teamId = TEAM_ID as string; + const target = TARGET_MEMBER as string; + + // Read current state so we can restore the original ordering on + // cleanup. If the target is not on the team the test would + // misconfigure cleanup, so we surface that as an assertion + // failure rather than silently skipping. + const members = await runEffect(getTeamMembers({ id: teamId })); + const member = members.find((m) => m.user.username === target); + expect(member).toBeDefined(); + const originalOrdering = member!.ordering ?? 0; + const newOrdering = originalOrdering + 1; + + const result = await runEffect( + modifyTeamMember({ + id: teamId, + id_or_username: target, + ordering: newOrdering, + }).pipe( + Effect.ensuring( + // Always restore the original ordering, even on failure. + // Effect.ignore swallows any failure of the restore call so + // cleanup never masks the real test result. + modifyTeamMember({ + id: teamId, + id_or_username: target, + ordering: originalOrdering, + }).pipe(Effect.ignore), + ), + ), + ); + + // PATCH returns 204/Void — verify the mutation took effect by + // re-reading the team and checking the new ordering value. + expect(result).toBeUndefined(); + const after = await runEffect(getTeamMembers({ id: teamId })); + const updated = after.find((m) => m.user.username === target); + expect(updated).toBeDefined(); + expect(updated!.ordering).toBe(newOrdering); + }, + 30_000, + ); + + it("returns BadRequest for an id that is not valid base62", async () => { + // Modrinth team ids are base62-encoded; the path validator rejects + // ids containing non-base62 characters (e.g. `!`) with a + // `400 invalid_input` before any auth or DB lookup, so the typed + // BadRequest is reachable without an API key. This 400 mapping is + // added by patches/002-add-mutation-bad-request.patch.json. + const error = await runEffect( + modifyTeamMember({ + id: `zz!${testRunId}`, + id_or_username: `user-${testRunId}`, + ordering: 0, + }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound for a base62-shaped team id that does not exist", + async () => { + // With auth and a base62-shaped team id Modrinth resolves the + // route, looks up the team, and returns 404 when nothing matches. + // We pad the testRunId so the path validator accepts it and the + // lookup actually fires. + const id = `zz${testRunId.slice(0, 6)}`; + const error = await runEffect( + modifyTeamMember({ + id, + id_or_username: `user-${testRunId}`, + ordering: 0, + }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // PATCH /team/{id}/members/{id_or_username} requires auth — only + // team admins may modify members. With a base62-shaped id the path + // validator passes, the auth check fires next, and Modrinth returns + // 401 without an API key. + const id = `zz${testRunId.slice(0, 6)}`; + const error = await Effect.runPromise( + modifyTeamMember({ + id, + id_or_username: `user-${testRunId}`, + ordering: 0, + }).pipe(Effect.flip, Effect.provide(NoAuthLayer)), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/modifyUser.test.ts b/packages/modrinth/test/modifyUser.test.ts new file mode 100644 index 000000000..671db7e7a --- /dev/null +++ b/packages/modrinth/test/modifyUser.test.ts @@ -0,0 +1,98 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { NotFound, Unauthorized } from "../src/errors.ts"; +import { getUser } from "../src/operations/getUser.ts"; +import { modifyUser } from "../src/operations/modifyUser.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +const OWNED_USER_ID = process.env.MODRINTH_TEST_OWNED_USER_ID; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("modifyUser", () => { + it.skipIf(!OWNED_USER_ID)( + "modifies the bio of the authenticated user and restores it", + async () => { + // PATCH /user/{id_or_username} returns 204 when the change is accepted. + // We capture the original bio first and use Effect.ensuring to restore + // it, so the test mutates a real user safely. The route requires the + // `username` field in the body even when only changing other fields, + // so we re-send the current username unchanged. + const id = OWNED_USER_ID as string; + const original = await runEffect(getUser({ id_or_username: id })); + const restoredBio = original.bio ?? ""; + const newBio = `distilled bio ${testRunId}`; + + await runEffect( + modifyUser({ + id_or_username: id, + username: original.username, + bio: newBio, + }).pipe( + Effect.ensuring( + modifyUser({ + id_or_username: id, + username: original.username, + bio: restoredBio, + }).pipe(Effect.ignore), + ), + ), + ); + + const updated = await runEffect(getUser({ id_or_username: id })); + expect(updated.bio).toBe(newBio); + }, + ); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound for a username that does not exist", + async () => { + // With auth, a username prefixed with "zz-distilled-" plus testRunId + // is guaranteed not to collide with any real account, so the route + // returns 404. + const username = `zz-distilled-${testRunId}`; + const error = await runEffect( + modifyUser({ + id_or_username: username, + username, + bio: `distilled bio ${testRunId}`, + }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // PATCH /user/{id_or_username} requires auth. We target jellysquid3 — a + // well-known public Modrinth account — so the path resolves and Modrinth + // reaches the auth check, which returns 401 with no API key. + const error = await Effect.runPromise( + modifyUser({ + id_or_username: "jellysquid3", + username: "jellysquid3", + bio: `distilled noauth ${testRunId}`, + }).pipe(Effect.flip, Effect.provide(NoAuthLayer)), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/modifyVersion.test.ts b/packages/modrinth/test/modifyVersion.test.ts new file mode 100644 index 000000000..1d68182cb --- /dev/null +++ b/packages/modrinth/test/modifyVersion.test.ts @@ -0,0 +1,91 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { NotFound, Unauthorized } from "../src/errors.ts"; +import { getProjectVersions } from "../src/operations/getProjectVersions.ts"; +import { getVersion } from "../src/operations/getVersion.ts"; +import { modifyVersion } from "../src/operations/modifyVersion.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +const OWNED_VERSION_ID = process.env.MODRINTH_TEST_OWNED_VERSION_ID; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("modifyVersion", () => { + it.skipIf(!OWNED_VERSION_ID)( + "modifies the name of a version the caller owns and restores it", + async () => { + // PATCH /version/{id} returns 204 when the change is accepted. We + // capture the original name first and use Effect.ensuring to restore + // it, so the test mutates a real version safely. + const id = OWNED_VERSION_ID as string; + const original = await runEffect(getVersion({ id })); + const restoredName = original.name; + const newName = `distilled-mr-version-${testRunId}`; + + await runEffect( + modifyVersion({ id, name: newName }).pipe( + Effect.ensuring( + modifyVersion({ id, name: restoredName }).pipe(Effect.ignore), + ), + ), + ); + }, + ); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound for a version id that does not exist", + async () => { + // With auth, an 8-character base62 id that doesn't exist yields a 404 + // from the version PATCH route. + const id = `zz${testRunId.slice(0, 6)}`; + const error = await runEffect( + modifyVersion({ id, name: `distilled-mr-${testRunId}` }).pipe( + Effect.flip, + ), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // PATCH /version/{id} requires auth. We fetch a real version id from the + // public `sodium` project so Modrinth resolves the path and reaches the + // auth check, which returns 401 with no API key. + const versions = await runEffect( + getProjectVersions({ + id_or_slug: "sodium", + include_changelog: false, + }), + ); + expect(versions.length).toBeGreaterThan(0); + const id = versions[0]!.id; + + const error = await Effect.runPromise( + modifyVersion({ id, name: `distilled-mr-noauth-${testRunId}` }).pipe( + Effect.flip, + Effect.provide(NoAuthLayer), + ), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/patchProjects.test.ts b/packages/modrinth/test/patchProjects.test.ts new file mode 100644 index 000000000..7ac6b99c7 --- /dev/null +++ b/packages/modrinth/test/patchProjects.test.ts @@ -0,0 +1,78 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { BadRequest, Unauthorized } from "../src/errors.ts"; +import { patchProjects } from "../src/operations/patchProjects.ts"; +import { runEffect } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; + +// Comma-separated list of project IDs/slugs the test account owns and is +// willing to bulk-edit (with a no-op patch). Provided externally so we never +// touch projects we don't intend to. +const OWNED_PROJECT_IDS_ENV = process.env.MODRINTH_TEST_OWNED_PROJECT_IDS; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("patchProjects", () => { + it.skipIf(!OWNED_PROJECT_IDS_ENV)( + "performs a no-op bulk patch on projects the caller owns", + async () => { + const owned = (OWNED_PROJECT_IDS_ENV as string) + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + // add_categories: [] is a no-op — no categories added — but is a valid + // body field, satisfying any "non-empty body" requirement. + await runEffect( + patchProjects({ + ids: JSON.stringify(owned), + add_categories: [], + }), + ); + }, + ); + + it.skipIf(!HAS_API_KEY)( + "returns BadRequest when ids is not valid JSON (with auth)", + async () => { + const error = await runEffect( + patchProjects({ + ids: "this-is-not-valid-json", + add_categories: [], + }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // PATCH /projects requires auth; Modrinth checks auth before validating + // the `ids` query, so a well-formed request without credentials is 401. + const error = await Effect.runPromise( + patchProjects({ + ids: JSON.stringify(["sodium"]), + add_categories: [], + }).pipe(Effect.flip, Effect.provide(NoAuthLayer)), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/projectTypeList.test.ts b/packages/modrinth/test/projectTypeList.test.ts new file mode 100644 index 000000000..cfb266358 --- /dev/null +++ b/packages/modrinth/test/projectTypeList.test.ts @@ -0,0 +1,55 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { Credentials, DEFAULT_USER_AGENT } from "../src/credentials.ts"; +import { NotFound } from "../src/errors.ts"; +import { projectTypeList } from "../src/operations/projectTypeList.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +// Layer that points the SDK at a non-existent path on the real Modrinth +// host. projectTypeList takes no parameters, so the only way to +// exercise a SDK-mapped error path is to redirect the base URL to a +// route that 404s. This proves the operation's status-code → +// typed-error mapping works on a parameterless GET. +const BogusBaseUrlLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: `https://api.modrinth.com/v2-nonexistent-${testRunId}`, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("projectTypeList", () => { + it("returns the public list of project types", async () => { + // GET /tag/project_type is a public, parameterless read endpoint. + // Modrinth ships with a baseline set of project types (mod, + // modpack, resourcepack, shader, etc.) so we expect a non-empty + // array of strings. + const projectTypes = await runEffect(projectTypeList({})); + + expect(Array.isArray(projectTypes)).toBe(true); + expect(projectTypes.length).toBeGreaterThan(0); + for (const projectType of projectTypes) { + expect(typeof projectType).toBe("string"); + expect(projectType.length).toBeGreaterThan(0); + } + }); + + it("returns NotFound when the base URL points to a non-existent path", async () => { + // projectTypeList has no input parameters, so the only + // deterministic way to provoke a typed error from the SDK is to + // override the base URL to a path that doesn't exist. Modrinth + // answers any unrecognized route with `404 not_found`, which the + // SDK maps to the typed `NotFound`. + const error = await Effect.runPromise( + projectTypeList({}).pipe( + Effect.flip, + Effect.provide(BogusBaseUrlLayer), + ), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }); +}); diff --git a/packages/modrinth/test/randomProjects.test.ts b/packages/modrinth/test/randomProjects.test.ts new file mode 100644 index 000000000..898bd2d2b --- /dev/null +++ b/packages/modrinth/test/randomProjects.test.ts @@ -0,0 +1,33 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; +import { BadRequest } from "../src/errors.ts"; +import { randomProjects } from "../src/operations/randomProjects.ts"; +import { runEffect } from "./setup.ts"; + +describe("randomProjects", () => { + it("returns the requested number of random projects", async () => { + const result = await runEffect(randomProjects({ count: 3 })); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(3); + for (const project of result) { + expect(typeof project.id).toBe("string"); + expect(project.id.length).toBeGreaterThan(0); + expect(typeof project.team).toBe("string"); + expect(typeof project.published).toBe("string"); + expect(typeof project.updated).toBe("string"); + expect(typeof project.followers).toBe("number"); + } + }); + + it("returns BadRequest when count exceeds Modrinth's allowed range", async () => { + // Modrinth limits `count` to a small range (max 100). Passing 10_000 + // triggers a 400 invalid_input "range" validation error. + const error = await runEffect( + randomProjects({ count: 10_000 }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }); +}); diff --git a/packages/modrinth/test/readNotification.test.ts b/packages/modrinth/test/readNotification.test.ts new file mode 100644 index 000000000..475e376b4 --- /dev/null +++ b/packages/modrinth/test/readNotification.test.ts @@ -0,0 +1,79 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { NotFound, Unauthorized } from "../src/errors.ts"; +import { getUserNotifications } from "../src/operations/getUserNotifications.ts"; +import { readNotification } from "../src/operations/readNotification.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +const OWNED_USER_ID = process.env.MODRINTH_TEST_OWNED_USER_ID; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("readNotification", () => { + it.skipIf(!OWNED_USER_ID)( + "marks an existing notification as read", + async () => { + // PATCH /notification/{id} marks the matching notification as read + // and returns 204. We bootstrap by listing the auth'd user's + // notifications and patching the first one. The operation is + // idempotent (re-marking an already-read notification still + // succeeds) so no rollback is needed; if the inbox is empty we skip + // the assertion since there is nothing real to mark. + const id = OWNED_USER_ID as string; + const inbox = await runEffect(getUserNotifications({ id_or_username: id })); + if (inbox.length === 0) { + return; + } + const notificationId = inbox[0]!.id; + + // Output schema is Schema.Void; success means no thrown error. + await runEffect(readNotification({ id: notificationId })); + }, + ); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound for a base62-shaped id that does not exist", + async () => { + // With auth and a base62-shaped id Modrinth resolves the route, + // looks up the notification, and returns 404 when nothing matches. + // We pad the testRunId to 8 base62 chars so the path validator + // accepts it and the lookup actually fires. + const id = `zz${testRunId.slice(0, 6)}`; + const error = await runEffect( + readNotification({ id }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // PATCH /notification/{id} requires auth. With a base62-shaped id the + // path validator passes, the auth check fires next, and Modrinth + // returns 401 without an API key. + const id = `zz${testRunId.slice(0, 6)}`; + const error = await Effect.runPromise( + readNotification({ id }).pipe(Effect.flip, Effect.provide(NoAuthLayer)), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/readNotifications.test.ts b/packages/modrinth/test/readNotifications.test.ts new file mode 100644 index 000000000..106831a01 --- /dev/null +++ b/packages/modrinth/test/readNotifications.test.ts @@ -0,0 +1,85 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { NotFound, Unauthorized } from "../src/errors.ts"; +import { getUserNotifications } from "../src/operations/getUserNotifications.ts"; +import { readNotifications } from "../src/operations/readNotifications.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +const OWNED_USER_ID = process.env.MODRINTH_TEST_OWNED_USER_ID; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("readNotifications", () => { + it.skipIf(!OWNED_USER_ID)( + "marks multiple notifications as read in bulk", + async () => { + // PATCH /notifications?ids=[...] marks every matching notification + // as read and returns 204. The route is idempotent (re-marking an + // already-read notification is a no-op) so no rollback is needed. + // We bootstrap by listing the auth'd user's inbox and patching the + // first up-to-2 ids; if the inbox is empty we still confirm the + // route accepts an empty array and replies with 204. + const id = OWNED_USER_ID as string; + const inbox = await runEffect(getUserNotifications({ id_or_username: id })); + + if (inbox.length === 0) { + await runEffect(readNotifications({ ids: JSON.stringify([]) })); + return; + } + + const ids = inbox.slice(0, Math.min(2, inbox.length)).map((n) => n.id); + // Output schema is Schema.Void; success means no thrown error. + await runEffect(readNotifications({ ids: JSON.stringify(ids) })); + }, + ); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound when none of the requested ids belong to the caller", + async () => { + // With auth Modrinth resolves the route, looks up each id, and + // reports 404 when the bulk request resolves to no matching + // notifications visible to the caller. We use base62-shaped ids + // padded with testRunId to guarantee non-collision with real + // notifications. + const ids = [`zz${testRunId.slice(0, 6)}`, `yy${testRunId.slice(0, 6)}`]; + const error = await runEffect( + readNotifications({ ids: JSON.stringify(ids) }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // PATCH /notifications requires auth. Modrinth runs the auth check + // before validating the contents of the `ids` query, so any + // well-formed request (even one with an empty ids array) yields 401 + // with no API key. + const error = await Effect.runPromise( + readNotifications({ ids: JSON.stringify([]) }).pipe( + Effect.flip, + Effect.provide(NoAuthLayer), + ), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/reportTypeList.test.ts b/packages/modrinth/test/reportTypeList.test.ts new file mode 100644 index 000000000..cce927924 --- /dev/null +++ b/packages/modrinth/test/reportTypeList.test.ts @@ -0,0 +1,55 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { Credentials, DEFAULT_USER_AGENT } from "../src/credentials.ts"; +import { NotFound } from "../src/errors.ts"; +import { reportTypeList } from "../src/operations/reportTypeList.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +// Layer that points the SDK at a non-existent path on the real Modrinth +// host. reportTypeList takes no parameters, so the only way to exercise +// a SDK-mapped error path is to redirect the base URL to a route that +// 404s. This proves the operation's status-code → typed-error mapping +// works on a parameterless GET. +const BogusBaseUrlLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: `https://api.modrinth.com/v2-nonexistent-${testRunId}`, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("reportTypeList", () => { + it("returns the public list of report types", async () => { + // GET /tag/report_type is a public, parameterless read endpoint. + // Modrinth ships with a baseline set of moderation report types + // (spam, copyright, etc.) so we expect a non-empty array of + // strings. + const reportTypes = await runEffect(reportTypeList({})); + + expect(Array.isArray(reportTypes)).toBe(true); + expect(reportTypes.length).toBeGreaterThan(0); + for (const reportType of reportTypes) { + expect(typeof reportType).toBe("string"); + expect(reportType.length).toBeGreaterThan(0); + } + }); + + it("returns NotFound when the base URL points to a non-existent path", async () => { + // reportTypeList has no input parameters, so the only deterministic + // way to provoke a typed error from the SDK is to override the + // base URL to a path that doesn't exist. Modrinth answers any + // unrecognized route with `404 not_found`, which the SDK maps to + // the typed `NotFound`. + const error = await Effect.runPromise( + reportTypeList({}).pipe( + Effect.flip, + Effect.provide(BogusBaseUrlLayer), + ), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }); +}); diff --git a/packages/modrinth/test/scheduleProject.test.ts b/packages/modrinth/test/scheduleProject.test.ts new file mode 100644 index 000000000..ed35ad031 --- /dev/null +++ b/packages/modrinth/test/scheduleProject.test.ts @@ -0,0 +1,93 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { BadRequest, Unauthorized } from "../src/errors.ts"; +import { scheduleProject } from "../src/operations/scheduleProject.ts"; +import { runEffect } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +// Modrinth requires the project to currently be in `processing` status for +// the schedule route to even exist — for any other status (or unknown slug) +// the API returns 404 regardless of auth state. The happy path AND the +// Unauthorized test therefore both need a project in `processing`. +const SCHEDULABLE_PROJECT_ID = process.env.MODRINTH_TEST_SCHEDULABLE_PROJECT_ID; +const OWNED_PROJECT_ID = process.env.MODRINTH_TEST_OWNED_PROJECT_ID; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +// One hour in the future as ISO 8601 — Modrinth requires the schedule time to +// be strictly in the future. +const futureIso = (): string => + new Date(Date.now() + 60 * 60 * 1000).toISOString(); + +describe("scheduleProject", () => { + it.skipIf(!SCHEDULABLE_PROJECT_ID)( + "schedules a project that is currently processing", + async () => { + // POST /project/{slug}/schedule returns 204 when the project is in the + // `processing` state and the requested status is one of the allowed + // transitions (e.g. `approved`). + const projectId = SCHEDULABLE_PROJECT_ID as string; + await runEffect( + scheduleProject({ + id_or_slug: projectId, + time: futureIso(), + requested_status: "approved", + }), + ); + }, + ); + + it.skipIf(!HAS_API_KEY || !OWNED_PROJECT_ID)( + "returns BadRequest when the schedule time is in the past", + async () => { + // With auth on an owned project the route reaches body validation; + // Modrinth rejects past timestamps with a 400 invalid_input. + const projectId = OWNED_PROJECT_ID as string; + const error = await runEffect( + scheduleProject({ + id_or_slug: projectId, + time: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + requested_status: "approved", + }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }, + ); + + it.skipIf(!SCHEDULABLE_PROJECT_ID)( + "returns Unauthorized when no API key is provided", + async () => { + // POST /project/{slug}/schedule requires auth. The route only exists + // for projects in `processing` state, so the project id must point at + // such a project for the auth check to be reachable. + const projectId = SCHEDULABLE_PROJECT_ID as string; + const error = await Effect.runPromise( + scheduleProject({ + id_or_slug: projectId, + time: futureIso(), + requested_status: "approved", + }).pipe(Effect.flip, Effect.provide(NoAuthLayer)), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }, + ); +}); diff --git a/packages/modrinth/test/scheduleVersion.test.ts b/packages/modrinth/test/scheduleVersion.test.ts new file mode 100644 index 000000000..a422e7e16 --- /dev/null +++ b/packages/modrinth/test/scheduleVersion.test.ts @@ -0,0 +1,93 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { BadRequest, Unauthorized } from "../src/errors.ts"; +import { scheduleVersion } from "../src/operations/scheduleVersion.ts"; +import { runEffect } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +// Modrinth's version-schedule route only exists for versions in a state that +// can be scheduled (e.g. `processing`/`scheduled`). For any other state — or +// for unknown ids — the API returns 404 regardless of auth. The happy path +// AND the Unauthorized test therefore both need a schedulable version id. +const SCHEDULABLE_VERSION_ID = process.env.MODRINTH_TEST_SCHEDULABLE_VERSION_ID; +const OWNED_VERSION_ID = process.env.MODRINTH_TEST_OWNED_VERSION_ID; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +// One hour in the future as ISO 8601 — Modrinth requires the schedule time to +// be strictly in the future. +const futureIso = (): string => + new Date(Date.now() + 60 * 60 * 1000).toISOString(); + +describe("scheduleVersion", () => { + it.skipIf(!SCHEDULABLE_VERSION_ID)( + "schedules a version that is currently in a schedulable state", + async () => { + // POST /version/{id}/schedule returns 204 when the version is in a + // schedulable state and the requested status is one of the allowed + // transitions (e.g. `approved`). + const id = SCHEDULABLE_VERSION_ID as string; + await runEffect( + scheduleVersion({ + id, + time: futureIso(), + requested_status: "approved", + }), + ); + }, + ); + + it.skipIf(!HAS_API_KEY || !OWNED_VERSION_ID)( + "returns BadRequest when the schedule time is in the past", + async () => { + // With auth on an owned version the route reaches body validation; + // Modrinth rejects past timestamps with a 400 invalid_input. + const id = OWNED_VERSION_ID as string; + const error = await runEffect( + scheduleVersion({ + id, + time: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + requested_status: "approved", + }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }, + ); + + it.skipIf(!SCHEDULABLE_VERSION_ID)( + "returns Unauthorized when no API key is provided", + async () => { + // POST /version/{id}/schedule requires auth. The route only exists for + // schedulable versions, so the version id must point at one for the + // auth check to be reachable. + const id = SCHEDULABLE_VERSION_ID as string; + const error = await Effect.runPromise( + scheduleVersion({ + id, + time: futureIso(), + requested_status: "approved", + }).pipe(Effect.flip, Effect.provide(NoAuthLayer)), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }, + ); +}); diff --git a/packages/modrinth/test/searchProjects.test.ts b/packages/modrinth/test/searchProjects.test.ts new file mode 100644 index 000000000..9256610ad --- /dev/null +++ b/packages/modrinth/test/searchProjects.test.ts @@ -0,0 +1,55 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; +import { BadRequest } from "../src/errors.ts"; +import { searchProjects } from "../src/operations/searchProjects.ts"; +import { runEffect } from "./setup.ts"; + +describe("searchProjects", () => { + it("can search projects without parameters", async () => { + const result = await runEffect(searchProjects({})); + + expect(Array.isArray(result.hits)).toBe(true); + expect(typeof result.offset).toBe("number"); + expect(typeof result.limit).toBe("number"); + expect(typeof result.total_hits).toBe("number"); + }); + + it("can search projects with a query string", async () => { + const result = await runEffect( + searchProjects({ query: "shader", limit: 5 }), + ); + + expect(Array.isArray(result.hits)).toBe(true); + expect(result.limit).toBe(5); + expect(result.hits.length).toBeLessThanOrEqual(5); + if (result.hits.length > 0) { + const hit = result.hits[0]!; + expect(typeof hit.project_id).toBe("string"); + expect(typeof hit.author).toBe("string"); + expect(Array.isArray(hit.versions)).toBe(true); + expect(typeof hit.follows).toBe("number"); + expect(typeof hit.date_created).toBe("string"); + expect(typeof hit.date_modified).toBe("string"); + expect(typeof hit.license).toBe("string"); + } + }); + + it("respects pagination via offset and limit", async () => { + const result = await runEffect( + searchProjects({ offset: 5, limit: 3, index: "downloads" }), + ); + + expect(result.limit).toBe(3); + expect(result.hits.length).toBeLessThanOrEqual(3); + expect(typeof result.offset).toBe("number"); + }); + + it("returns BadRequest for malformed facets JSON", async () => { + const error = await runEffect( + searchProjects({ facets: "this-is-not-valid-json" }).pipe(Effect.flip), + ); + + expect(error).toBeInstanceOf(BadRequest); + expect(error._tag).toBe("BadRequest"); + }); +}); diff --git a/packages/modrinth/test/sendThreadMessage.test.ts b/packages/modrinth/test/sendThreadMessage.test.ts new file mode 100644 index 000000000..89ddb3996 --- /dev/null +++ b/packages/modrinth/test/sendThreadMessage.test.ts @@ -0,0 +1,138 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { BadRequest, NotFound, Unauthorized } from "../src/errors.ts"; +import { deleteThreadMessage } from "../src/operations/deleteThreadMessage.ts"; +import { getOpenReports } from "../src/operations/getOpenReports.ts"; +import { sendThreadMessage } from "../src/operations/sendThreadMessage.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +// POST /thread/{id} writes a real, moderator-visible message into a live +// Modrinth thread. Even though we self-clean via deleteThreadMessage, +// keep the happy path opt-in (mirrors submitReport.test.ts gating) so CI +// runs without the env var don't poke at live moderation state. +const ALLOW_SEND = process.env.MODRINTH_TEST_ALLOW_SEND_THREAD_MESSAGE === "1"; +const SHOULD_RUN_HAPPY = HAS_API_KEY && ALLOW_SEND; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("sendThreadMessage", () => { + it.skipIf(!SHOULD_RUN_HAPPY)( + "appends a text message to an existing thread and cleans it up", + async () => { + // Bootstrap: list the auth'd user's open reports and post into the + // first report's thread. If the queue is empty we cannot exercise + // the round-trip and return early — the listing call still + // confirms auth. + const open = await runEffect(getOpenReports({})); + const seed = open.find((r) => typeof r.thread_id === "string"); + if (!seed) { + return; + } + const threadId = seed.thread_id; + const messageBody = `distilled SDK sendThreadMessage probe — please ignore (run ${testRunId})`; + + const updated = await runEffect( + sendThreadMessage({ + id: threadId, + type: "text", + body: messageBody, + }), + ); + + // The response is the updated thread including all messages. Locate + // the message we just posted by its body, then delete it via + // ensuring-style cleanup so the moderator queue isn't littered. + expect(updated.id).toBe(threadId); + const posted = updated.messages.find( + (m) => m.body.type === "text" && m.body.body === messageBody, + ); + expect(posted).toBeDefined(); + const postedId = posted!.id; + + try { + expect(typeof postedId).toBe("string"); + expect(["project", "report", "direct_message"]).toContain(updated.type); + expect(Array.isArray(updated.members)).toBe(true); + } finally { + // Always clean up the message we posted, even if the assertions + // above throw. Effect.ignore swallows any failure of the delete + // call itself so cleanup never masks the real test result. + await runEffect( + deleteThreadMessage({ id: postedId }).pipe(Effect.ignore), + ); + } + }, + 30_000, + ); + + it("returns BadRequest for an id that is not valid base62", async () => { + // Modrinth ids are base62-encoded; the path validator rejects ids + // containing non-base62 characters (e.g. `!`) with a + // `400 invalid_input` before any auth or DB lookup, so the typed + // BadRequest is reachable without an API key. + const error = await runEffect( + sendThreadMessage({ + id: `zz!${testRunId}`, + type: "text", + body: "noop", + }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound for a base62-shaped id that does not exist", + async () => { + // With auth and a base62-shaped id Modrinth resolves the route, + // looks up the thread, and returns 404 when nothing matches. We + // pad the testRunId so the path validator accepts it and the + // lookup actually fires. + const id = `zz${testRunId.slice(0, 6)}`; + const error = await runEffect( + sendThreadMessage({ + id, + type: "text", + body: `distilled SDK NotFound probe (run ${testRunId})`, + }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // POST /thread/{id} requires auth. With a base62-shaped id the path + // validator passes, the auth check fires next, and Modrinth returns + // 401 without an API key. + const id = `zz${testRunId.slice(0, 6)}`; + const error = await Effect.runPromise( + sendThreadMessage({ + id, + type: "text", + body: `distilled SDK Unauthorized probe (run ${testRunId})`, + }).pipe(Effect.flip, Effect.provide(NoAuthLayer)), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/setup.ts b/packages/modrinth/test/setup.ts new file mode 100644 index 000000000..3198ee00e --- /dev/null +++ b/packages/modrinth/test/setup.ts @@ -0,0 +1,28 @@ +import { config } from "dotenv"; +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { CredentialsFromEnv } from "../src/credentials.ts"; + +// Load environment variables from .env file +config(); + +// Main layer providing credentials and HTTP client for all tests +export const TestLayer = Layer.merge(CredentialsFromEnv, FetchHttpClient.layer); + +/** + * Short random hex string generated once per test run. + * Append this to resource names so parallel test runs don't collide. + */ +export const testRunId: string = crypto + .randomUUID() + .replace(/-/g, "") + .slice(0, 8); + +/** + * Run an Effect with the TestLayer provided. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const runEffect = (effect: Effect.Effect): Promise => + Effect.runPromise( + effect.pipe(Effect.provide(TestLayer)) as Effect.Effect, + ); diff --git a/packages/modrinth/test/sideTypeList.test.ts b/packages/modrinth/test/sideTypeList.test.ts new file mode 100644 index 000000000..34db0f59a --- /dev/null +++ b/packages/modrinth/test/sideTypeList.test.ts @@ -0,0 +1,52 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { Credentials, DEFAULT_USER_AGENT } from "../src/credentials.ts"; +import { NotFound } from "../src/errors.ts"; +import { sideTypeList } from "../src/operations/sideTypeList.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +// Layer that points the SDK at a non-existent path on the real Modrinth +// host. sideTypeList takes no parameters, so the only way to exercise a +// SDK-mapped error path is to redirect the base URL to a route that +// 404s. This proves the operation's status-code → typed-error mapping +// works on a parameterless GET. +const BogusBaseUrlLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: `https://api.modrinth.com/v2-nonexistent-${testRunId}`, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("sideTypeList", () => { + it("returns the public list of side types", async () => { + // GET /tag/side_type is a public, parameterless read endpoint. + // Modrinth ships with a baseline set of side types (required, + // optional, unsupported) so we expect a non-empty array of + // strings. + const sideTypes = await runEffect(sideTypeList({})); + + expect(Array.isArray(sideTypes)).toBe(true); + expect(sideTypes.length).toBeGreaterThan(0); + for (const sideType of sideTypes) { + expect(typeof sideType).toBe("string"); + expect(sideType.length).toBeGreaterThan(0); + } + }); + + it("returns NotFound when the base URL points to a non-existent path", async () => { + // sideTypeList has no input parameters, so the only deterministic + // way to provoke a typed error from the SDK is to override the + // base URL to a path that doesn't exist. Modrinth answers any + // unrecognized route with `404 not_found`, which the SDK maps to + // the typed `NotFound`. + const error = await Effect.runPromise( + sideTypeList({}).pipe(Effect.flip, Effect.provide(BogusBaseUrlLayer)), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }); +}); diff --git a/packages/modrinth/test/statistics.test.ts b/packages/modrinth/test/statistics.test.ts new file mode 100644 index 000000000..30bcd5d9e --- /dev/null +++ b/packages/modrinth/test/statistics.test.ts @@ -0,0 +1,64 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { Credentials, DEFAULT_USER_AGENT } from "../src/credentials.ts"; +import { NotFound } from "../src/errors.ts"; +import { statistics } from "../src/operations/statistics.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +// Layer that points the SDK at a non-existent path on the real Modrinth +// host. statistics takes no parameters, so the only way to exercise an +// SDK-mapped error path is to redirect the base URL to a route that +// 404s. This proves the operation's status-code → typed-error mapping +// works on a parameterless GET. +const BogusBaseUrlLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: `https://api.modrinth.com/v2-nonexistent-${testRunId}`, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("statistics", () => { + it("returns the public Modrinth instance statistics", async () => { + // GET /statistics is a public, parameterless read endpoint that + // returns aggregate counts for the running Modrinth instance. + // All four documented fields are optional, so we only assert the + // shape — every present field must be a non-negative number. + const stats = await runEffect(statistics({})); + + expect(typeof stats).toBe("object"); + expect(stats).not.toBeNull(); + if (stats.projects !== undefined) { + expect(typeof stats.projects).toBe("number"); + expect(stats.projects).toBeGreaterThanOrEqual(0); + } + if (stats.versions !== undefined) { + expect(typeof stats.versions).toBe("number"); + expect(stats.versions).toBeGreaterThanOrEqual(0); + } + if (stats.files !== undefined) { + expect(typeof stats.files).toBe("number"); + expect(stats.files).toBeGreaterThanOrEqual(0); + } + if (stats.authors !== undefined) { + expect(typeof stats.authors).toBe("number"); + expect(stats.authors).toBeGreaterThanOrEqual(0); + } + }); + + it("returns NotFound when the base URL points to a non-existent path", async () => { + // statistics has no input parameters, so the only deterministic + // way to provoke a typed error from the SDK is to override the + // base URL to a path that doesn't exist. Modrinth answers any + // unrecognized route with `404 not_found`, which the SDK maps to + // the typed `NotFound`. + const error = await Effect.runPromise( + statistics({}).pipe(Effect.flip, Effect.provide(BogusBaseUrlLayer)), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }); +}); diff --git a/packages/modrinth/test/submitReport.test.ts b/packages/modrinth/test/submitReport.test.ts new file mode 100644 index 000000000..40540cbb9 --- /dev/null +++ b/packages/modrinth/test/submitReport.test.ts @@ -0,0 +1,110 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { BadRequest, Unauthorized } from "../src/errors.ts"; +import { submitReport } from "../src/operations/submitReport.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +const OWNED_USER_ID = process.env.MODRINTH_TEST_OWNED_USER_ID; +// POST /report files a real moderation report against a real Modrinth resource +// — there is no "sandbox" report and the report goes straight onto the +// moderator queue. The happy path is therefore double-gated: callers must +// explicitly set MODRINTH_TEST_ALLOW_SUBMIT_REPORT=1 in addition to providing +// an owned user id, and the report targets the auth'd user themselves +// (item_type=user, item_id=OWNED_USER_ID) with a body that flags it as a +// distilled SDK test so moderators can dismiss it quickly. If the opt-in +// variable isn't set the happy path skips. +const ALLOW_SUBMIT = process.env.MODRINTH_TEST_ALLOW_SUBMIT_REPORT === "1"; +const SHOULD_RUN_HAPPY = !!OWNED_USER_ID && ALLOW_SUBMIT; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("submitReport", () => { + it.skipIf(!SHOULD_RUN_HAPPY)( + "files a self-report and returns the persisted report", + async () => { + // Self-report the auth'd user with a clear "distilled test" body so + // moderators can identify and dismiss the report quickly. Modrinth + // returns the persisted report including its server-issued id and + // thread_id. There is no API to delete an open report, so this is + // intentionally low-frequency and gated on MODRINTH_TEST_ALLOW_SUBMIT_REPORT. + const id = OWNED_USER_ID as string; + const report = await runEffect( + submitReport({ + report_type: "spam", + item_id: id, + item_type: "user", + body: `distilled SDK test report — please ignore (run ${testRunId})`, + }), + ); + + expect(report.report_type).toBe("spam"); + expect(report.item_id).toBe(id); + expect(report.item_type).toBe("user"); + expect(typeof report.body).toBe("string"); + expect(typeof report.reporter).toBe("string"); + expect(typeof report.created).toBe("string"); + expect(report.closed).toBe(false); + expect(typeof report.thread_id).toBe("string"); + if (report.id !== undefined) { + expect(typeof report.id).toBe("string"); + } + }, + ); + + it.skipIf(!HAS_API_KEY)( + "returns BadRequest for an item_id that does not match a project", + async () => { + // Modrinth validates that `item_id` resolves to a resource of + // `item_type`. A syntactically-valid base62-shaped id that does not + // correspond to any project causes a 400 invalid_input response (the + // moderation pipeline cannot file a report against a phantom resource), + // which the SDK maps to the typed `BadRequest`. + const phantomId = `zz${testRunId}`; + const error = await runEffect( + submitReport({ + report_type: "spam", + item_id: phantomId, + item_type: "project", + body: `distilled SDK BadRequest probe — phantom id (run ${testRunId})`, + }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // POST /report requires authentication — moderation reports must be + // attributable to a real reporter. Modrinth runs the auth check before + // any payload validation, so any well-formed body yields 401 with no + // API key, mapped by the SDK to the typed `Unauthorized`. + const error = await Effect.runPromise( + submitReport({ + report_type: "spam", + item_id: "00000000", + item_type: "project", + body: `distilled SDK Unauthorized probe (run ${testRunId})`, + }).pipe(Effect.flip, Effect.provide(NoAuthLayer)), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/transferTeamOwnership.test.ts b/packages/modrinth/test/transferTeamOwnership.test.ts new file mode 100644 index 000000000..563c211d3 --- /dev/null +++ b/packages/modrinth/test/transferTeamOwnership.test.ts @@ -0,0 +1,149 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { BadRequest, NotFound, Unauthorized } from "../src/errors.ts"; +import { transferTeamOwnership } from "../src/operations/transferTeamOwnership.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +// PATCH /team/{id}/owner permanently transfers team ownership. Once +// transferred, the original owner loses admin privileges, so a +// self-restoring happy path requires a second API key for the partner +// who can transfer ownership back. The happy path is six-way gated: +// - MODRINTH_API_KEY — current owner +// - MODRINTH_TEST_TRANSFER_TEAM_ID — sacrificial test team +// - MODRINTH_TEST_TRANSFER_PARTNER_USER_ID — partner's user id +// - MODRINTH_TEST_TRANSFER_PARTNER_API_KEY — partner's API key +// - MODRINTH_TEST_OWNED_USER_ID — original owner's id +// (target of restore) +// - MODRINTH_TEST_ALLOW_TRANSFER_OWNERSHIP=1 — explicit opt-in +const TRANSFER_TEAM_ID = process.env.MODRINTH_TEST_TRANSFER_TEAM_ID; +const PARTNER_USER_ID = process.env.MODRINTH_TEST_TRANSFER_PARTNER_USER_ID; +const PARTNER_API_KEY = process.env.MODRINTH_TEST_TRANSFER_PARTNER_API_KEY; +const OWNED_USER_ID = process.env.MODRINTH_TEST_OWNED_USER_ID; +const ALLOW_TRANSFER = + process.env.MODRINTH_TEST_ALLOW_TRANSFER_OWNERSHIP === "1"; +const SHOULD_RUN_HAPPY = + HAS_API_KEY && + !!TRANSFER_TEAM_ID && + !!PARTNER_USER_ID && + !!PARTNER_API_KEY && + !!OWNED_USER_ID && + ALLOW_TRANSFER; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +// Layer authenticated as the partner user, used to transfer ownership +// back to the original owner during cleanup. Only constructed when the +// partner key env var is set. +const PartnerAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: PARTNER_API_KEY, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("transferTeamOwnership", () => { + it.skipIf(!SHOULD_RUN_HAPPY)( + "transfers ownership to a partner user and restores it on cleanup", + async () => { + const teamId = TRANSFER_TEAM_ID as string; + const partnerId = PARTNER_USER_ID as string; + const ownerId = OWNED_USER_ID as string; + + const result = await runEffect( + transferTeamOwnership({ id: teamId, user_id: partnerId }).pipe( + Effect.ensuring( + // The original owner no longer has permission to transfer + // back, so cleanup MUST run as the partner. We swap the + // credentials layer for the cleanup call only by piping + // Effect.provide(PartnerAuthLayer) on the inner effect — + // that overrides the TestLayer credentials supplied by + // runEffect for this sub-call. Effect.ignore swallows any + // failure of the restore so cleanup never masks the real + // test result; if restore fails the team remains owned by + // the partner and a human will need to intervene. + transferTeamOwnership({ id: teamId, user_id: ownerId }).pipe( + Effect.provide(PartnerAuthLayer), + Effect.ignore, + ), + ), + ), + ); + + // PATCH returns 204/Void. + expect(result).toBeUndefined(); + }, + 30_000, + ); + + it("returns BadRequest for an id that is not valid base62", async () => { + // Modrinth team ids are base62-encoded; the path validator rejects + // ids containing non-base62 characters (e.g. `!`) with a + // `400 invalid_input` before any auth or DB lookup, so the typed + // BadRequest is reachable without an API key. This 400 mapping is + // added by patches/002-add-mutation-bad-request.patch.json. + const error = await runEffect( + transferTeamOwnership({ + id: `zz!${testRunId}`, + user_id: `zz${testRunId.slice(0, 6)}`, + }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound for a base62-shaped team id that does not exist", + async () => { + // With auth and a base62-shaped team id Modrinth resolves the + // route, looks up the team, and returns 404 when nothing matches. + // We pad the testRunId so the path validator accepts it and the + // lookup actually fires. + const id = `zz${testRunId.slice(0, 6)}`; + const error = await runEffect( + transferTeamOwnership({ + id, + user_id: `zz${testRunId.slice(0, 6)}`, + }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // PATCH /team/{id}/owner requires auth — only the current team + // owner may transfer ownership. With a base62-shaped id the path + // validator passes, the auth check fires next, and Modrinth returns + // 401 without an API key. + const id = `zz${testRunId.slice(0, 6)}`; + const error = await Effect.runPromise( + transferTeamOwnership({ + id, + user_id: `zz${testRunId.slice(0, 6)}`, + }).pipe(Effect.flip, Effect.provide(NoAuthLayer)), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/unfollowProject.test.ts b/packages/modrinth/test/unfollowProject.test.ts new file mode 100644 index 000000000..91dc996a7 --- /dev/null +++ b/packages/modrinth/test/unfollowProject.test.ts @@ -0,0 +1,76 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { + Credentials, + DEFAULT_API_BASE_URL, + DEFAULT_USER_AGENT, +} from "../src/credentials.ts"; +import { BadRequest, Unauthorized } from "../src/errors.ts"; +import { followProject } from "../src/operations/followProject.ts"; +import { unfollowProject } from "../src/operations/unfollowProject.ts"; +import { runEffect } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; + +// `sodium` is a public, long-lived Modrinth project that any authenticated +// user can follow/unfollow. +const TEST_FOLLOW_SLUG = "sodium"; + +// Layer with no API key so we can deterministically trigger 401 even when +// MODRINTH_API_KEY is set in the environment. +const NoAuthLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: DEFAULT_API_BASE_URL, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("unfollowProject", () => { + it.skipIf(!HAS_API_KEY)( + "unfollows a project the caller is currently following", + async () => { + // Set up the precondition: ensure the caller is following the project, + // then call the operation under test (unfollow) and assert it succeeds. + await runEffect( + followProject({ id_or_slug: TEST_FOLLOW_SLUG }).pipe(Effect.ignore), + ); + + await runEffect(unfollowProject({ id_or_slug: TEST_FOLLOW_SLUG })); + }, + ); + + it.skipIf(!HAS_API_KEY)( + "returns BadRequest when unfollowing a project that is not followed", + async () => { + // Make sure we're not following the project, then a second unfollow + // yields a 400 invalid_input from Modrinth. + await runEffect( + unfollowProject({ id_or_slug: TEST_FOLLOW_SLUG }).pipe(Effect.ignore), + ); + + const error = await runEffect( + unfollowProject({ id_or_slug: TEST_FOLLOW_SLUG }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("BadRequest"); + expect(error).toBeInstanceOf(BadRequest); + }, + ); + + it("returns Unauthorized when no API key is provided", async () => { + // DELETE /project/{slug}/follow requires auth. With a valid known slug + // Modrinth reaches the auth check and returns 401. + const error = await Effect.runPromise( + unfollowProject({ id_or_slug: TEST_FOLLOW_SLUG }).pipe( + Effect.flip, + Effect.provide(NoAuthLayer), + ), + ); + + expect(error._tag).toBe("Unauthorized"); + expect(error).toBeInstanceOf(Unauthorized); + }); +}); diff --git a/packages/modrinth/test/versionFromHash.test.ts b/packages/modrinth/test/versionFromHash.test.ts new file mode 100644 index 000000000..bc55bfc9c --- /dev/null +++ b/packages/modrinth/test/versionFromHash.test.ts @@ -0,0 +1,65 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; +import { NotFound } from "../src/errors.ts"; +import { getProjectVersions } from "../src/operations/getProjectVersions.ts"; +import { versionFromHash } from "../src/operations/versionFromHash.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +describe("versionFromHash", () => { + it("resolves a version from a real sha1 hash", async () => { + // Pull a real (sha1, sha512) pair off the latest sodium version's primary + // file and round-trip it through GET /version_file/{hash}. The route + // does not require auth and returns the matching version object. + const versions = await runEffect( + getProjectVersions({ + id_or_slug: "sodium", + include_changelog: false, + }), + ); + expect(versions.length).toBeGreaterThan(0); + const file = versions[0]!.files[0]!; + const sha1 = file.hashes.sha1; + expect(typeof sha1).toBe("string"); + + const result = await runEffect( + versionFromHash({ hash: sha1 as string, algorithm: "sha1" }), + ); + + expect(result.id).toBe(versions[0]!.id); + expect(typeof result.project_id).toBe("string"); + expect(typeof result.version_number).toBe("string"); + }); + + it("resolves a version from a real sha512 hash", async () => { + // The same route accepts sha512 hashes when `algorithm` is set + // accordingly. + const versions = await runEffect( + getProjectVersions({ + id_or_slug: "sodium", + include_changelog: false, + }), + ); + expect(versions.length).toBeGreaterThan(0); + const file = versions[0]!.files[0]!; + const sha512 = file.hashes.sha512; + expect(typeof sha512).toBe("string"); + + const result = await runEffect( + versionFromHash({ hash: sha512 as string, algorithm: "sha512" }), + ); + + expect(result.id).toBe(versions[0]!.id); + }); + + it("returns NotFound for a hash that does not match any file", async () => { + // A 40-character hex string is shaped like a sha1 hash but with the + // testRunId baked in is guaranteed not to match any uploaded file. + const hash = "0".repeat(32) + testRunId; + const error = await runEffect( + versionFromHash({ hash, algorithm: "sha1" }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }); +}); diff --git a/packages/modrinth/test/versionList.test.ts b/packages/modrinth/test/versionList.test.ts new file mode 100644 index 000000000..3bb64c833 --- /dev/null +++ b/packages/modrinth/test/versionList.test.ts @@ -0,0 +1,56 @@ +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { describe, expect, it } from "vitest"; +import { Credentials, DEFAULT_USER_AGENT } from "../src/credentials.ts"; +import { NotFound } from "../src/errors.ts"; +import { versionList } from "../src/operations/versionList.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +// Layer that points the SDK at a non-existent path on the real Modrinth +// host. versionList takes no parameters, so the only way to exercise a +// SDK-mapped error path is to redirect the base URL to a route that +// 404s. This proves the operation's status-code → typed-error mapping +// works on a parameterless GET. +const BogusBaseUrlLayer = Layer.merge( + Layer.succeed(Credentials, { + apiKey: undefined, + apiBaseUrl: `https://api.modrinth.com/v2-nonexistent-${testRunId}`, + userAgent: DEFAULT_USER_AGENT, + }), + FetchHttpClient.layer, +); + +describe("versionList", () => { + it("returns the public list of game versions", async () => { + // GET /tag/game_version is a public, parameterless read endpoint. + // Modrinth has tracked Minecraft versions for years so we expect a + // sizable array; we assert the array shape and the documented + // fields on each entry. + const versions = await runEffect(versionList({})); + + expect(Array.isArray(versions)).toBe(true); + expect(versions.length).toBeGreaterThan(0); + for (const version of versions) { + expect(typeof version.version).toBe("string"); + expect(["release", "snapshot", "alpha", "beta"]).toContain( + version.version_type, + ); + expect(typeof version.date).toBe("string"); + expect(typeof version.major).toBe("boolean"); + } + }); + + it("returns NotFound when the base URL points to a non-existent path", async () => { + // versionList has no input parameters, so the only deterministic + // way to provoke a typed error from the SDK is to override the + // base URL to a path that doesn't exist. Modrinth answers any + // unknown route with `404 not_found`, which the SDK maps to the + // typed `NotFound`. + const error = await Effect.runPromise( + versionList({}).pipe(Effect.flip, Effect.provide(BogusBaseUrlLayer)), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }); +}); diff --git a/packages/modrinth/test/versionsFromHashes.test.ts b/packages/modrinth/test/versionsFromHashes.test.ts new file mode 100644 index 000000000..e8b320875 --- /dev/null +++ b/packages/modrinth/test/versionsFromHashes.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { getProjectVersions } from "../src/operations/getProjectVersions.ts"; +import { versionsFromHashes } from "../src/operations/versionsFromHashes.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +describe("versionsFromHashes", () => { + it("returns a record of versions keyed by sha1 hash", async () => { + // POST /version_files takes an array of sha1/sha512 hashes plus an + // algorithm and returns a Record for every hash that + // resolves. We harvest two real sha1 hashes off sodium's two latest + // versions so the response actually has entries to assert on. + const versions = await runEffect( + getProjectVersions({ + id_or_slug: "sodium", + include_changelog: false, + }), + ); + expect(versions.length).toBeGreaterThanOrEqual(2); + const sha1A = versions[0]!.files[0]!.hashes.sha1; + const sha1B = versions[1]!.files[0]!.hashes.sha1; + expect(typeof sha1A).toBe("string"); + expect(typeof sha1B).toBe("string"); + + const result = await runEffect( + versionsFromHashes({ + hashes: [sha1A as string, sha1B as string], + algorithm: "sha1", + }), + ); + + expect(typeof result).toBe("object"); + expect(Object.keys(result).length).toBeGreaterThanOrEqual(1); + // Modrinth keys the response by the matched file's hash. Both of our + // requested hashes correspond to sodium versions, so every entry should + // share the sodium project id. + for (const version of Object.values(result)) { + expect(version.project_id).toBe(versions[0]!.project_id); + expect(typeof version.id).toBe("string"); + expect(typeof version.version_number).toBe("string"); + } + }); + + it("returns a record of versions keyed by sha512 hash", async () => { + // The same route accepts sha512 hashes when `algorithm` is "sha512". + const versions = await runEffect( + getProjectVersions({ + id_or_slug: "sodium", + include_changelog: false, + }), + ); + expect(versions.length).toBeGreaterThan(0); + const sha512 = versions[0]!.files[0]!.hashes.sha512; + expect(typeof sha512).toBe("string"); + + const result = await runEffect( + versionsFromHashes({ + hashes: [sha512 as string], + algorithm: "sha512", + }), + ); + + const entries = Object.values(result); + expect(entries.length).toBe(1); + expect(entries[0]!.id).toBe(versions[0]!.id); + }); + + it("returns an empty record when no hashes match any file", async () => { + // For hashes that don't match any uploaded file Modrinth returns 200 with + // an empty object — this is not BadRequest, just a non-match. We bake + // testRunId into a sha1-shaped string to guarantee no collision with real + // files. + const hash = "0".repeat(32) + testRunId; + const result = await runEffect( + versionsFromHashes({ + hashes: [hash], + algorithm: "sha1", + }), + ); + + expect(result).toEqual({}); + }); + + // BadRequest note: + // Modrinth's `/version_files` route only returns 400 when the body is + // missing `hashes`/`algorithm`, contains non-string elements in `hashes`, + // or is not a struct at all. The SDK's typed input schema + // (`Schema.Array(Schema.String)` for hashes, `Schema.Literals(["sha1", + // "sha512"])` for algorithm) rejects every one of those shapes at + // `Schema.encode` time before any HTTP request leaves the client, so + // there is no input the typed SDK can send that Modrinth answers with a + // 400 BadRequest. All malformed-body 400s surface as a synchronous + // `SchemaError`, not a `BadRequest`, so they cannot be exercised here + // without bypassing the SDK entirely. +}); diff --git a/packages/modrinth/test/withdrawPayout.test.ts b/packages/modrinth/test/withdrawPayout.test.ts new file mode 100644 index 000000000..ac6436551 --- /dev/null +++ b/packages/modrinth/test/withdrawPayout.test.ts @@ -0,0 +1,75 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; +import { NotFound } from "../src/errors.ts"; +import { withdrawPayout } from "../src/operations/withdrawPayout.ts"; +import { runEffect, testRunId } from "./setup.ts"; + +const HAS_API_KEY = !!process.env.MODRINTH_API_KEY; +const OWNED_USER_ID = process.env.MODRINTH_TEST_OWNED_USER_ID; +// Withdrawing real money is irreversible, so the happy path is double-gated: +// callers must explicitly set MODRINTH_TEST_PAYOUT_WITHDRAWAL_AMOUNT to the +// (small) amount they're willing to withdraw, in addition to providing the +// owned user id. If the variable isn't set (or isn't a number), the happy +// path skips. +const RAW_AMOUNT = process.env.MODRINTH_TEST_PAYOUT_WITHDRAWAL_AMOUNT; +const PAYOUT_AMOUNT = + RAW_AMOUNT && Number.isFinite(Number(RAW_AMOUNT)) + ? Number(RAW_AMOUNT) + : undefined; +const SHOULD_RUN_HAPPY = !!OWNED_USER_ID && PAYOUT_AMOUNT !== undefined; + +describe("withdrawPayout", () => { + it.skipIf(!SHOULD_RUN_HAPPY)( + "withdraws the configured amount from the authenticated user's balance", + async () => { + // POST /user/{id_or_username}/payouts triggers a real payout to the + // wallet configured on the user's account. There is no rollback — + // funds leave Modrinth's balance and head to PayPal/Venmo — so the + // test is gated on an explicit MODRINTH_TEST_PAYOUT_WITHDRAWAL_AMOUNT + // opt-in. Output schema is Schema.Void; success means no thrown + // error. + const id = OWNED_USER_ID as string; + await runEffect( + withdrawPayout({ id_or_username: id, amount: PAYOUT_AMOUNT as number }), + ); + }, + ); + + it.skipIf(!HAS_API_KEY)( + "returns NotFound for a username that does not exist", + async () => { + // With a valid API key, Modrinth resolves the route, looks up the + // user, and returns 404 when the username is unknown. Without auth + // the same path collapses to a route-level 404 instead (see + // Unauthorized note below), so this assertion only meaningfully + // distinguishes "user not found" from "route hidden" when run with a + // real PAT. + const username = `zz-distilled-${testRunId}`; + const error = await runEffect( + withdrawPayout({ + id_or_username: username, + amount: 0.01, + }).pipe(Effect.flip), + ); + + expect(error._tag).toBe("NotFound"); + expect(error).toBeInstanceOf(NotFound); + }, + ); + + // Unauthorized note: + // Like GET /user/{id}/payouts, the POST withdrawal route does *not* + // return 401 to unauthenticated callers — Modrinth answers + // `404 not_found "the requested route does not exist"` for every + // request without a valid token (no header, empty header, or a + // syntactically-valid invalid token). Empirically: + // - no Authorization header → 404 not_found + // - empty Authorization header → 404 not_found + // - syntactically-valid invalid token → 404 not_found + // The SDK's typed `matchError` faithfully maps the 404 response to + // `NotFound`, so there is no input the SDK can produce that surfaces + // the typed `Unauthorized` branch for this operation. The route hides + // itself from unauth'd callers as an information-leak protection, + // which makes `Unauthorized` unreachable through this typed + // operation. +}); diff --git a/packages/modrinth/tsconfig.json b/packages/modrinth/tsconfig.json new file mode 100644 index 000000000..3760701b6 --- /dev/null +++ b/packages/modrinth/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [ + "src/**/*.ts" + ], + "compilerOptions": { + "outDir": "./lib", + "rootDir": "./src", + "paths": { + "~/*": [ + "./src/*" + ] + } + }, + "references": [ + { + "path": "../core" + } + ] +} \ No newline at end of file diff --git a/packages/modrinth/tsconfig.test.json b/packages/modrinth/tsconfig.test.json new file mode 100644 index 000000000..b2af39b25 --- /dev/null +++ b/packages/modrinth/tsconfig.test.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ], + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + "paths": { + "~/*": [ + "./src/*" + ] + } + } +} \ No newline at end of file diff --git a/packages/modrinth/vitest.config.ts b/packages/modrinth/vitest.config.ts new file mode 100644 index 000000000..fdad53402 --- /dev/null +++ b/packages/modrinth/vitest.config.ts @@ -0,0 +1,17 @@ +import { config } from "dotenv"; +import { resolve } from "path"; + +config({ path: resolve(__dirname, "../../.env") }); +config({ path: resolve(__dirname, ".env") }); + +export default { + test: { + include: ["test/**/*.test.ts"], + testTimeout: 120000, + }, + resolve: { + alias: { + "~": new URL("./src", import.meta.url).pathname, + }, + }, +};