Skip to content

Commit 25e7a0b

Browse files
clayoteMathieu Fenniak
authored andcommitted
feat: support simple JSON API for PyPI package registry (#12095)
This PR extends Forĝejo's PyPI package index to support [the simple JSON repository API](https://packaging.python.org/en/latest/specifications/simple-repository-api/#json-serialization). Since the existing implementation was for the HTML serialization of the same simple API, no new endpoint has been added. Instead, Forĝejo chooses between serialization schemes based on the "Accept" header in the request. This, together with CORS, will make Forĝejo compatible with [micropip](https://github.com/pyodide/micropip). ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. All work and communication must conform to Forgejo's [AI Agreement](https://codeberg.org/forgejo/governance/src/branch/main/AIAgreement.md). There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests for Go changes (can be removed for JavaScript changes) - I added test coverage for Go changes... - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I ran... - [x] `make pr-go` before pushing ### Documentation - [x] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - [x] I did not document these changes and I do not expect someone else to do it. ### Release notes - [x] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change. - [ ] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change. *The decision if the pull request will be shown in the release notes is up to the mergers / release team.* The content of the `release-notes/<pull request number>.md` file will serve as the basis for the release notes. If the file does not exist, the title of the pull request will be used instead. <!--start release-notes-assistant--> ## Release notes <!--URL:https://codeberg.org/forgejo/forgejo--> - Features - [PR](https://codeberg.org/forgejo/forgejo/pulls/12095): <!--number 12095 --><!--line 0 --><!--description SG9zdGVkIFB5UEkgcGFja2FnZXMgbWF5IGJlIGFjY2Vzc2VkIHZpYSB0aGUgW3NpbXBsZSBKU09OIEFQSV0oaHR0cHM6Ly9wYWNrYWdpbmcucHl0aG9uLm9yZy9lbi9sYXRlc3Qvc3BlY2lmaWNhdGlvbnMvc2ltcGxlLXJlcG9zaXRvcnktYXBpLyNqc29uLXNlcmlhbGl6YXRpb24pIGluIGFkZGl0aW9uIHRvIHRoZSBzaW1wbGUgSFRNTCBBUEkgYWxyZWFkeSBhdmFpbGFibGUu-->Hosted PyPI packages may be accessed via the [simple JSON API](https://packaging.python.org/en/latest/specifications/simple-repository-api/#json-serialization) in addition to the simple HTML API already available.<!--description--> <!--end release-notes-assistant--> Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12095 Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
1 parent 81c46e4 commit 25e7a0b

4 files changed

Lines changed: 133 additions & 5 deletions

File tree

modules/packages/pypi/metadata.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,26 @@ type Metadata struct {
1313
License string `json:"license,omitempty"`
1414
RequiresPython string `json:"requires_python,omitempty"`
1515
}
16+
17+
type FileHashesJSON struct {
18+
SHA256 string `json:"sha256"`
19+
}
20+
21+
type FileJSON struct {
22+
Filename string `json:"filename"`
23+
URL string `json:"url"`
24+
Hashes FileHashesJSON `json:"hashes"`
25+
RequiresPython string `json:"requires-python"`
26+
Size int64 `json:"size"`
27+
}
28+
29+
type PackageMetaJSON struct {
30+
APIVersion string `json:"api-version"`
31+
}
32+
33+
type PackageJSON struct {
34+
Name string `json:"name"`
35+
Meta PackageMetaJSON `json:"meta"`
36+
Versions []string `json:"versions"`
37+
Files []FileJSON `json:"files"`
38+
}

release-notes/12095.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Hosted PyPI packages may be accessed via the [simple JSON API](https://packaging.python.org/en/latest/specifications/simple-repository-api/#json-serialization) in addition to the simple HTML API already available.

routers/api/packages/pypi/pypi.go

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@ import (
88
"io"
99
"net/http"
1010
"regexp"
11+
"slices"
1112
"sort"
1213
"strings"
1314
"unicode"
1415

1516
packages_model "forgejo.org/models/packages"
17+
"forgejo.org/modules/json"
18+
"forgejo.org/modules/log"
1619
packages_module "forgejo.org/modules/packages"
1720
pypi_module "forgejo.org/modules/packages/pypi"
1821
"forgejo.org/modules/setting"
@@ -44,8 +47,14 @@ func apiError(ctx *context.Context, status int, obj any) {
4447
})
4548
}
4649

47-
// PackageMetadata returns the metadata for a single package
48-
func PackageMetadata(ctx *context.Context) {
50+
func contentTypeSupported(ctyps []string, v string) bool {
51+
return slices.ContainsFunc(ctyps, func(ctyp string) bool {
52+
return strings.HasPrefix(ctyp, v)
53+
})
54+
}
55+
56+
// HTMLPackageMetadata returns the metadata for a single package in Simple HTML per PEP691
57+
func HTMLPackageMetadata(ctx *context.Context) {
4958
packageName := normalizer.Replace(ctx.Params("id"))
5059

5160
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypePyPI, packageName)
@@ -72,9 +81,82 @@ func PackageMetadata(ctx *context.Context) {
7281
ctx.Data["RegistryURL"] = setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/pypi"
7382
ctx.Data["PackageDescriptor"] = pds[0]
7483
ctx.Data["PackageDescriptors"] = pds
84+
// Content-Type headers need to be in this order for the page to show in the browser
85+
ctx.Resp.Header().Set("Content-Type", "application/vnd.pypi.simple.v1+html")
86+
ctx.Resp.Header().Add("Content-Type", "text/html")
7587
ctx.HTML(http.StatusOK, "api/packages/pypi/simple")
7688
}
7789

90+
// JSONPackageMetadata returns the metadata for a single package in Simple JSON per PEP691
91+
func JSONPackageMetadata(ctx *context.Context) {
92+
packageName := normalizer.Replace(ctx.Params("id"))
93+
94+
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypePyPI, packageName)
95+
if err != nil {
96+
apiError(ctx, http.StatusInternalServerError, err)
97+
return
98+
}
99+
if len(pvs) == 0 {
100+
apiError(ctx, http.StatusNotFound, err)
101+
return
102+
}
103+
104+
pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
105+
if err != nil {
106+
apiError(ctx, http.StatusInternalServerError, err)
107+
return
108+
}
109+
110+
// sort package descriptors by version to mimic PyPI format
111+
slices.SortFunc(pds, func(a, b *packages_model.PackageDescriptor) int {
112+
return strings.Compare(a.Version.Version, b.Version.Version)
113+
})
114+
registryURL := setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/pypi"
115+
versions := make([]string, len(pvs))
116+
for i, pv := range pvs {
117+
versions[i] = pv.Version
118+
}
119+
var fileCounter int
120+
for _, pd := range pds {
121+
fileCounter += len(pd.Files)
122+
}
123+
files := make([]pypi_module.FileJSON, fileCounter)
124+
var i int
125+
for _, pd := range pds {
126+
for _, file := range pd.Files {
127+
files[i] = pypi_module.FileJSON{
128+
Filename: file.File.Name,
129+
URL: registryURL + "/files/" + pd.Package.LowerName + "/" + pd.Version.Version + "/" + file.File.Name,
130+
RequiresPython: pd.Metadata.(*pypi_module.Metadata).RequiresPython,
131+
Hashes: pypi_module.FileHashesJSON{SHA256: file.Blob.HashSHA256},
132+
Size: file.Blob.Size,
133+
}
134+
i++
135+
}
136+
}
137+
content := pypi_module.PackageJSON{
138+
Name: pds[0].Package.Name,
139+
Meta: pypi_module.PackageMetaJSON{APIVersion: "1.4"},
140+
Versions: versions,
141+
Files: files,
142+
}
143+
ctx.Resp.Header().Set("Content-Type", "application/vnd.pypi.simple.v1+json")
144+
ctx.Resp.Header().Add("Content-Type", "application/json")
145+
if err := json.NewEncoder(ctx.Resp).Encode(content); err != nil {
146+
log.Error("Render JSON failed: %v", err)
147+
apiError(ctx, http.StatusInternalServerError, err)
148+
}
149+
}
150+
151+
func PackageMetadata(ctx *context.Context) {
152+
ctyp := ctx.Req.Header["Accept"]
153+
if contentTypeSupported(ctyp, "application/vnd.pypi.simple.v1+json") {
154+
JSONPackageMetadata(ctx)
155+
} else {
156+
HTMLPackageMetadata(ctx)
157+
}
158+
}
159+
78160
// DownloadPackageFile serves the content of a package
79161
func DownloadPackageFile(ctx *context.Context) {
80162
packageName := normalizer.Replace(ctx.Params("id"))

tests/integration/api_packages_pypi_test.go

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"forgejo.org/models/packages"
1818
"forgejo.org/models/unittest"
1919
user_model "forgejo.org/models/user"
20+
"forgejo.org/modules/json"
2021
"forgejo.org/modules/packages/pypi"
2122
"forgejo.org/tests"
2223

@@ -211,19 +212,20 @@ func TestPackagePyPI(t *testing.T) {
211212
assert.Equal(t, int64(2), pvs[0].DownloadCount)
212213
})
213214

214-
t.Run("PackageMetadata", func(t *testing.T) {
215+
hrefMatcher := regexp.MustCompile(fmt.Sprintf(`%s/files/%s/%s/test\..+#sha256=%s`, root, regexp.QuoteMeta(packageName), regexp.QuoteMeta(packageVersion), hashSHA256))
216+
217+
t.Run("PackageMetadataHTML", func(t *testing.T) {
215218
defer tests.PrintCurrentTest(t)()
216219

217220
req := NewRequest(t, "GET", fmt.Sprintf("%s/simple/%s", root, packageName)).
218221
AddBasicAuth(user.Name)
222+
req.Header["Accept"] = []string{"application/vnd.pypi.simple.v1+html"}
219223
resp := MakeRequest(t, req, http.StatusOK)
220224

221225
htmlDoc := NewHTMLParser(t, resp.Body)
222226
nodes := htmlDoc.doc.Find("a").Nodes
223227
assert.Len(t, nodes, 2)
224228

225-
hrefMatcher := regexp.MustCompile(fmt.Sprintf(`%s/files/%s/%s/test\..+#sha256=%s`, root, regexp.QuoteMeta(packageName), regexp.QuoteMeta(packageVersion), hashSHA256))
226-
227229
for _, a := range nodes {
228230
for _, att := range a.Attr {
229231
switch att.Key {
@@ -237,4 +239,24 @@ func TestPackagePyPI(t *testing.T) {
237239
}
238240
}
239241
})
242+
243+
t.Run("PackageMetadataJSON", func(t *testing.T) {
244+
defer tests.PrintCurrentTest(t)()
245+
246+
req := NewRequest(t, "GET", fmt.Sprintf("%s/simple/%s", root, packageName)).
247+
AddBasicAuth(user.Name)
248+
req.Header["Accept"] = []string{"application/vnd.pypi.simple.v1+json"}
249+
resp := MakeRequest(t, req, http.StatusOK)
250+
assert.Greater(t, resp.Body.Len(), 3)
251+
txt := make([]byte, resp.Body.Len())
252+
resp.Body.Read(txt)
253+
var obj pypi.PackageJSON
254+
require.NoError(t, json.Unmarshal(txt, &obj))
255+
assert.Equal(t, packageName, obj.Name)
256+
assert.Equal(t, pypi.PackageMetaJSON{APIVersion: "1.4"}, obj.Meta)
257+
for _, filed := range obj.Files {
258+
hrefMatcher = regexp.MustCompile(fmt.Sprintf(`%s/files/%s/%s/test\.(tar\.gz)|(whl)`, root, regexp.QuoteMeta(packageName), regexp.QuoteMeta(packageVersion)))
259+
assert.Regexp(t, hrefMatcher, filed.URL[21:])
260+
}
261+
})
240262
}

0 commit comments

Comments
 (0)