From c709fa17a77eae391cafbe72d6b2594f74d86a60 Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Mon, 13 Mar 2023 21:28:39 +0100 Subject: [PATCH] Add Swift package registry (#22404) This PR adds a [Swift](https://www.swift.org/) package registry. ![grafik](https://user-images.githubusercontent.com/1666336/211842523-07521cbd-8fb6-400f-820c-ee8048b05ae8.png) --- custom/conf/app.example.ini | 2 + .../doc/advanced/config-cheat-sheet.en-us.md | 1 + docs/content/doc/packages/overview.en-us.md | 1 + docs/content/doc/packages/swift.en-us.md | 93 ++++ models/packages/descriptor.go | 3 + models/packages/package.go | 6 + modules/packages/swift/metadata.go | 214 ++++++++ modules/packages/swift/metadata_test.go | 144 ++++++ modules/setting/packages.go | 2 + options/locale/locale_en-US.ini | 4 + public/img/svg/gitea-swift.svg | 1 + routers/api/packages/api.go | 36 ++ routers/api/packages/swift/swift.go | 464 ++++++++++++++++++ routers/api/v1/packages/package.go | 2 +- services/forms/package_form.go | 2 +- services/packages/packages.go | 2 + templates/package/content/swift.tmpl | 40 ++ templates/package/metadata/swift.tmpl | 4 + templates/package/view.tmpl | 2 + templates/swagger/v1_json.tmpl | 1 + tests/integration/api_packages_swift_test.go | 326 ++++++++++++ web_src/svg/gitea-swift.svg | 5 + 22 files changed, 1353 insertions(+), 2 deletions(-) create mode 100644 docs/content/doc/packages/swift.en-us.md create mode 100644 modules/packages/swift/metadata.go create mode 100644 modules/packages/swift/metadata_test.go create mode 100644 public/img/svg/gitea-swift.svg create mode 100644 routers/api/packages/swift/swift.go create mode 100644 templates/package/content/swift.tmpl create mode 100644 templates/package/metadata/swift.tmpl create mode 100644 tests/integration/api_packages_swift_test.go create mode 100644 web_src/svg/gitea-swift.svg diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index b2b5af0af8..e53ed7ad9f 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -2516,6 +2516,8 @@ ROUTER = console ;LIMIT_SIZE_PYPI = -1 ;; Maximum size of a RubyGems upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) ;LIMIT_SIZE_RUBYGEMS = -1 +;; Maximum size of a Swift upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) +;LIMIT_SIZE_SWIFT = -1 ;; Maximum size of a Vagrant upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) ;LIMIT_SIZE_VAGRANT = -1 diff --git a/docs/content/doc/advanced/config-cheat-sheet.en-us.md b/docs/content/doc/advanced/config-cheat-sheet.en-us.md index b67d6cdf5f..4b9c519cd8 100644 --- a/docs/content/doc/advanced/config-cheat-sheet.en-us.md +++ b/docs/content/doc/advanced/config-cheat-sheet.en-us.md @@ -1254,6 +1254,7 @@ Task queue configuration has been moved to `queue.task`. However, the below conf - `LIMIT_SIZE_PUB`: **-1**: Maximum size of a Pub upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) - `LIMIT_SIZE_PYPI`: **-1**: Maximum size of a PyPI upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) - `LIMIT_SIZE_RUBYGEMS`: **-1**: Maximum size of a RubyGems upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) +- `LIMIT_SIZE_SWIFT`: **-1**: Maximum size of a Swift upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) - `LIMIT_SIZE_VAGRANT`: **-1**: Maximum size of a Vagrant upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) ## Mirror (`mirror`) diff --git a/docs/content/doc/packages/overview.en-us.md b/docs/content/doc/packages/overview.en-us.md index f93fec6393..08da8ced48 100644 --- a/docs/content/doc/packages/overview.en-us.md +++ b/docs/content/doc/packages/overview.en-us.md @@ -40,6 +40,7 @@ The following package managers are currently supported: | [Pub]({{< relref "doc/packages/pub.en-us.md" >}}) | Dart | `dart`, `flutter` | | [PyPI]({{< relref "doc/packages/pypi.en-us.md" >}}) | Python | `pip`, `twine` | | [RubyGems]({{< relref "doc/packages/rubygems.en-us.md" >}}) | Ruby | `gem`, `Bundler` | +| [Swift]({{< relref "doc/packages/rubygems.en-us.md" >}}) | Swift | `swift` | | [Vagrant]({{< relref "doc/packages/vagrant.en-us.md" >}}) | - | `vagrant` | **The following paragraphs only apply if Packages are not globally disabled!** diff --git a/docs/content/doc/packages/swift.en-us.md b/docs/content/doc/packages/swift.en-us.md new file mode 100644 index 0000000000..61a4c9a55d --- /dev/null +++ b/docs/content/doc/packages/swift.en-us.md @@ -0,0 +1,93 @@ +--- +date: "2023-01-10T00:00:00+00:00" +title: "Swift Packages Repository" +slug: "packages/swift" +draft: false +toc: false +menu: + sidebar: + parent: "packages" + name: "Swift" + weight: 95 + identifier: "swift" +--- + +# Swift Packages Repository + +Publish [Swift](hhttps://www.swift.org/) packages for your user or organization. + +**Table of Contents** + +{{< toc >}} + +## Requirements + +To work with the Swift package registry, you need to use [swift](https://www.swift.org/getting-started/) to consume and a HTTP client (like `curl`) to publish packages. + +## Configuring the package registry + +To register the package registry and provide credentials, execute: + +```shell +swift package-registry set https://gitea.example.com/api/packages/{owner}/swift -login {username} -password {password} +``` + +| Placeholder | Description | +| ------------ | ----------- | +| `owner` | The owner of the package. | +| `username` | Your Gitea username. | +| `password` | Your Gitea password. If you are using 2FA or OAuth use a [personal access token]({{< relref "doc/developers/api-usage.en-us.md#authentication" >}}) instead of the password. | + +The login is optional and only needed if the package registry is private. + +## Publish a package + +First you have to pack the contents of your package: + +```shell +swift package archive-source +``` + +To publish the package perform a HTTP PUT request with the package content in the request body. + +```shell --user your_username:your_password_or_token \ +curl -X PUT --user {username}:{password} \ + -H "Accept: application/vnd.swift.registry.v1+json" \ + -F source-archive=@/path/to/package.zip \ + -F metadata={metadata} \ + https://gitea.example.com/api/packages/{owner}/swift/{scope}/{name}/{version} +``` + +| Placeholder | Description | +| ----------- | ----------- | +| `username` | Your Gitea username. | +| `password` | Your Gitea password. If you are using 2FA or OAuth use a [personal access token]({{< relref "doc/developers/api-usage.en-us.md#authentication" >}}) instead of the password. | +| `owner` | The owner of the package. | +| `scope` | The package scope. | +| `name` | The package name. | +| `version` | The package version. | +| `metadata` | (Optional) The metadata of the package. JSON encoded subset of https://schema.org/SoftwareSourceCode | + +You cannot publish a package if a package of the same name and version already exists. You must delete the existing package first. + +## Install a package + +To install a Swift package from the package registry, add it in the `Package.swift` file dependencies list: + +``` +dependencies: [ + .package(id: "{scope}.{name}", from:"{version}") +] +``` + +| Parameter | Description | +| ----------- | ----------- | +| `scope` | The package scope. | +| `name` | The package name. | +| `version` | The package version. | + +Afterwards execute the following command to install it: + +```shell +swift package resolve +``` diff --git a/models/packages/descriptor.go b/models/packages/descriptor.go index f4be21e74e..06699b5d57 100644 --- a/models/packages/descriptor.go +++ b/models/packages/descriptor.go @@ -24,6 +24,7 @@ import ( "code.gitea.io/gitea/modules/packages/pub" "code.gitea.io/gitea/modules/packages/pypi" "code.gitea.io/gitea/modules/packages/rubygems" + "code.gitea.io/gitea/modules/packages/swift" "code.gitea.io/gitea/modules/packages/vagrant" "github.com/hashicorp/go-version" @@ -159,6 +160,8 @@ func GetPackageDescriptor(ctx context.Context, pv *PackageVersion) (*PackageDesc metadata = &pypi.Metadata{} case TypeRubyGems: metadata = &rubygems.Metadata{} + case TypeSwift: + metadata = &swift.Metadata{} case TypeVagrant: metadata = &vagrant.Metadata{} default: diff --git a/models/packages/package.go b/models/packages/package.go index 32f30fab9b..ccc9257c31 100644 --- a/models/packages/package.go +++ b/models/packages/package.go @@ -44,6 +44,7 @@ const ( TypePub Type = "pub" TypePyPI Type = "pypi" TypeRubyGems Type = "rubygems" + TypeSwift Type = "swift" TypeVagrant Type = "vagrant" ) @@ -62,6 +63,7 @@ var TypeList = []Type{ TypePub, TypePyPI, TypeRubyGems, + TypeSwift, TypeVagrant, } @@ -96,6 +98,8 @@ func (pt Type) Name() string { return "PyPI" case TypeRubyGems: return "RubyGems" + case TypeSwift: + return "Swift" case TypeVagrant: return "Vagrant" } @@ -133,6 +137,8 @@ func (pt Type) SVGName() string { return "gitea-python" case TypeRubyGems: return "gitea-rubygems" + case TypeSwift: + return "gitea-swift" case TypeVagrant: return "gitea-vagrant" } diff --git a/modules/packages/swift/metadata.go b/modules/packages/swift/metadata.go new file mode 100644 index 0000000000..24c4262ab7 --- /dev/null +++ b/modules/packages/swift/metadata.go @@ -0,0 +1,214 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package swift + +import ( + "archive/zip" + "fmt" + "io" + "path" + "regexp" + "strings" + + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/validation" + + "github.com/hashicorp/go-version" +) + +var ( + ErrMissingManifestFile = util.NewInvalidArgumentErrorf("Package.swift file is missing") + ErrManifestFileTooLarge = util.NewInvalidArgumentErrorf("Package.swift file is too large") + ErrInvalidManifestVersion = util.NewInvalidArgumentErrorf("manifest version is invalid") + + manifestPattern = regexp.MustCompile(`\APackage(?:@swift-(\d+(?:\.\d+)?(?:\.\d+)?))?\.swift\z`) + toolsVersionPattern = regexp.MustCompile(`\A// swift-tools-version:(\d+(?:\.\d+)?(?:\.\d+)?)`) +) + +const ( + maxManifestFileSize = 128 * 1024 + + PropertyScope = "swift.scope" + PropertyName = "swift.name" + PropertyRepositoryURL = "swift.repository_url" +) + +// Package represents a Swift package +type Package struct { + RepositoryURLs []string + Metadata *Metadata +} + +// Metadata represents the metadata of a Swift package +type Metadata struct { + Description string `json:"description,omitempty"` + Keywords []string `json:"keywords,omitempty"` + RepositoryURL string `json:"repository_url,omitempty"` + License string `json:"license,omitempty"` + Author Person `json:"author,omitempty"` + Manifests map[string]*Manifest `json:"manifests,omitempty"` +} + +// Manifest represents a Package.swift file +type Manifest struct { + Content string `json:"content"` + ToolsVersion string `json:"tools_version,omitempty"` +} + +// https://schema.org/SoftwareSourceCode +type SoftwareSourceCode struct { + Context []string `json:"@context"` + Type string `json:"@type"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description,omitempty"` + Keywords []string `json:"keywords,omitempty"` + CodeRepository string `json:"codeRepository,omitempty"` + License string `json:"license,omitempty"` + Author Person `json:"author"` + ProgrammingLanguage ProgrammingLanguage `json:"programmingLanguage"` + RepositoryURLs []string `json:"repositoryURLs,omitempty"` +} + +// https://schema.org/ProgrammingLanguage +type ProgrammingLanguage struct { + Type string `json:"@type"` + Name string `json:"name"` + URL string `json:"url"` +} + +// https://schema.org/Person +type Person struct { + Type string `json:"@type,omitempty"` + GivenName string `json:"givenName,omitempty"` + MiddleName string `json:"middleName,omitempty"` + FamilyName string `json:"familyName,omitempty"` +} + +func (p Person) String() string { + var sb strings.Builder + if p.GivenName != "" { + sb.WriteString(p.GivenName) + } + if p.MiddleName != "" { + if sb.Len() > 0 { + sb.WriteRune(' ') + } + sb.WriteString(p.MiddleName) + } + if p.FamilyName != "" { + if sb.Len() > 0 { + sb.WriteRune(' ') + } + sb.WriteString(p.FamilyName) + } + return sb.String() +} + +// ParsePackage parses the Swift package upload +func ParsePackage(sr io.ReaderAt, size int64, mr io.Reader) (*Package, error) { + zr, err := zip.NewReader(sr, size) + if err != nil { + return nil, err + } + + p := &Package{ + Metadata: &Metadata{ + Manifests: make(map[string]*Manifest), + }, + } + + for _, file := range zr.File { + manifestMatch := manifestPattern.FindStringSubmatch(path.Base(file.Name)) + if len(manifestMatch) == 0 { + continue + } + + if file.UncompressedSize64 > maxManifestFileSize { + return nil, ErrManifestFileTooLarge + } + + f, err := zr.Open(file.Name) + if err != nil { + return nil, err + } + + content, err := io.ReadAll(f) + + if err := f.Close(); err != nil { + return nil, err + } + + if err != nil { + return nil, err + } + + swiftVersion := "" + if len(manifestMatch) == 2 && manifestMatch[1] != "" { + v, err := version.NewSemver(manifestMatch[1]) + if err != nil { + return nil, ErrInvalidManifestVersion + } + swiftVersion = TrimmedVersionString(v) + } + + manifest := &Manifest{ + Content: string(content), + } + + toolsMatch := toolsVersionPattern.FindStringSubmatch(manifest.Content) + if len(toolsMatch) == 2 { + v, err := version.NewSemver(toolsMatch[1]) + if err != nil { + return nil, ErrInvalidManifestVersion + } + + manifest.ToolsVersion = TrimmedVersionString(v) + } + + p.Metadata.Manifests[swiftVersion] = manifest + } + + if _, found := p.Metadata.Manifests[""]; !found { + return nil, ErrMissingManifestFile + } + + if mr != nil { + var ssc *SoftwareSourceCode + if err := json.NewDecoder(mr).Decode(&ssc); err != nil { + return nil, err + } + + p.Metadata.Description = ssc.Description + p.Metadata.Keywords = ssc.Keywords + p.Metadata.License = ssc.License + p.Metadata.Author = Person{ + GivenName: ssc.Author.GivenName, + MiddleName: ssc.Author.MiddleName, + FamilyName: ssc.Author.FamilyName, + } + + p.Metadata.RepositoryURL = ssc.CodeRepository + if !validation.IsValidURL(p.Metadata.RepositoryURL) { + p.Metadata.RepositoryURL = "" + } + + p.RepositoryURLs = ssc.RepositoryURLs + } + + return p, nil +} + +// TrimmedVersionString returns the version string without the patch segment if it is zero +func TrimmedVersionString(v *version.Version) string { + segments := v.Segments64() + + var b strings.Builder + fmt.Fprintf(&b, "%d.%d", segments[0], segments[1]) + if segments[2] != 0 { + fmt.Fprintf(&b, ".%d", segments[2]) + } + return b.String() +} diff --git a/modules/packages/swift/metadata_test.go b/modules/packages/swift/metadata_test.go new file mode 100644 index 0000000000..3913c2355b --- /dev/null +++ b/modules/packages/swift/metadata_test.go @@ -0,0 +1,144 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package swift + +import ( + "archive/zip" + "bytes" + "strings" + "testing" + + "github.com/hashicorp/go-version" + "github.com/stretchr/testify/assert" +) + +const ( + packageName = "gitea" + packageVersion = "1.0.1" + packageDescription = "Package Description" + packageRepositoryURL = "https://gitea.io/gitea/gitea" + packageAuthor = "KN4CK3R" + packageLicense = "MIT" +) + +func TestParsePackage(t *testing.T) { + createArchive := func(files map[string][]byte) *bytes.Reader { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for filename, content := range files { + w, _ := zw.Create(filename) + w.Write(content) + } + zw.Close() + return bytes.NewReader(buf.Bytes()) + } + + t.Run("MissingManifestFile", func(t *testing.T) { + data := createArchive(map[string][]byte{"dummy.txt": {}}) + + p, err := ParsePackage(data, data.Size(), nil) + assert.Nil(t, p) + assert.ErrorIs(t, err, ErrMissingManifestFile) + }) + + t.Run("ManifestFileTooLarge", func(t *testing.T) { + data := createArchive(map[string][]byte{ + "Package.swift": make([]byte, maxManifestFileSize+1), + }) + + p, err := ParsePackage(data, data.Size(), nil) + assert.Nil(t, p) + assert.ErrorIs(t, err, ErrManifestFileTooLarge) + }) + + t.Run("WithoutMetadata", func(t *testing.T) { + content1 := "// swift-tools-version:5.7\n//\n// Package.swift" + content2 := "// swift-tools-version:5.6\n//\n// Package@swift-5.6.swift" + + data := createArchive(map[string][]byte{ + "Package.swift": []byte(content1), + "Package@swift-5.5.swift": []byte(content2), + }) + + p, err := ParsePackage(data, data.Size(), nil) + assert.NotNil(t, p) + assert.NoError(t, err) + + assert.NotNil(t, p.Metadata) + assert.Empty(t, p.RepositoryURLs) + assert.Len(t, p.Metadata.Manifests, 2) + m := p.Metadata.Manifests[""] + assert.Equal(t, "5.7", m.ToolsVersion) + assert.Equal(t, content1, m.Content) + m = p.Metadata.Manifests["5.5"] + assert.Equal(t, "5.6", m.ToolsVersion) + assert.Equal(t, content2, m.Content) + }) + + t.Run("WithMetadata", func(t *testing.T) { + data := createArchive(map[string][]byte{ + "Package.swift": []byte("// swift-tools-version:5.7\n//\n// Package.swift"), + }) + + p, err := ParsePackage( + data, + data.Size(), + strings.NewReader(`{"name":"`+packageName+`","version":"`+packageVersion+`","description":"`+packageDescription+`","keywords":["swift","package"],"license":"`+packageLicense+`","codeRepository":"`+packageRepositoryURL+`","author":{"givenName":"`+packageAuthor+`"},"repositoryURLs":["`+packageRepositoryURL+`"]}`), + ) + assert.NotNil(t, p) + assert.NoError(t, err) + + assert.NotNil(t, p.Metadata) + assert.Len(t, p.Metadata.Manifests, 1) + m := p.Metadata.Manifests[""] + assert.Equal(t, "5.7", m.ToolsVersion) + + assert.Equal(t, packageDescription, p.Metadata.Description) + assert.ElementsMatch(t, []string{"swift", "package"}, p.Metadata.Keywords) + assert.Equal(t, packageLicense, p.Metadata.License) + assert.Equal(t, packageAuthor, p.Metadata.Author.GivenName) + assert.Equal(t, packageRepositoryURL, p.Metadata.RepositoryURL) + assert.ElementsMatch(t, []string{packageRepositoryURL}, p.RepositoryURLs) + }) +} + +func TestTrimmedVersionString(t *testing.T) { + cases := []struct { + Version *version.Version + Expected string + }{ + { + Version: version.Must(version.NewVersion("1")), + Expected: "1.0", + }, + { + Version: version.Must(version.NewVersion("1.0")), + Expected: "1.0", + }, + { + Version: version.Must(version.NewVersion("1.0.0")), + Expected: "1.0", + }, + { + Version: version.Must(version.NewVersion("1.0.1")), + Expected: "1.0.1", + }, + { + Version: version.Must(version.NewVersion("1.0+meta")), + Expected: "1.0", + }, + { + Version: version.Must(version.NewVersion("1.0.0+meta")), + Expected: "1.0", + }, + { + Version: version.Must(version.NewVersion("1.0.1+meta")), + Expected: "1.0.1", + }, + } + + for _, c := range cases { + assert.Equal(t, c.Expected, TrimmedVersionString(c.Version)) + } +} diff --git a/modules/setting/packages.go b/modules/setting/packages.go index 13599e5a63..ac0ad62bca 100644 --- a/modules/setting/packages.go +++ b/modules/setting/packages.go @@ -39,6 +39,7 @@ var ( LimitSizePub int64 LimitSizePyPI int64 LimitSizeRubyGems int64 + LimitSizeSwift int64 LimitSizeVagrant int64 }{ Enabled: true, @@ -81,6 +82,7 @@ func loadPackagesFrom(rootCfg ConfigProvider) { Packages.LimitSizePub = mustBytes(sec, "LIMIT_SIZE_PUB") Packages.LimitSizePyPI = mustBytes(sec, "LIMIT_SIZE_PYPI") Packages.LimitSizeRubyGems = mustBytes(sec, "LIMIT_SIZE_RUBYGEMS") + Packages.LimitSizeSwift = mustBytes(sec, "LIMIT_SIZE_SWIFT") Packages.LimitSizeVagrant = mustBytes(sec, "LIMIT_SIZE_VAGRANT") } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 677af1397d..e793c3ef03 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -3239,6 +3239,10 @@ rubygems.dependencies.development = Development Dependencies rubygems.required.ruby = Requires Ruby version rubygems.required.rubygems = Requires RubyGem version rubygems.documentation = For more information on the RubyGems registry, see the documentation. +swift.registry = Setup this registry from the command line: +swift.install = Add the package in your Package.swift file: +swift.install2 = and run the following command: +swift.documentation = For more information on the Swift registry, see the documentation. vagrant.install = To add a Vagrant box, run the following command: vagrant.documentation = For more information on the Vagrant registry, see the documentation. settings.link = Link this package to a repository diff --git a/public/img/svg/gitea-swift.svg b/public/img/svg/gitea-swift.svg new file mode 100644 index 0000000000..ebfea951da --- /dev/null +++ b/public/img/svg/gitea-swift.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go index 0e3d8b7a02..c0c7b117f6 100644 --- a/routers/api/packages/api.go +++ b/routers/api/packages/api.go @@ -28,6 +28,7 @@ import ( "code.gitea.io/gitea/routers/api/packages/pub" "code.gitea.io/gitea/routers/api/packages/pypi" "code.gitea.io/gitea/routers/api/packages/rubygems" + "code.gitea.io/gitea/routers/api/packages/swift" "code.gitea.io/gitea/routers/api/packages/vagrant" "code.gitea.io/gitea/services/auth" context_service "code.gitea.io/gitea/services/context" @@ -375,6 +376,41 @@ func CommonRoutes(ctx gocontext.Context) *web.Route { r.Delete("/yank", rubygems.DeletePackage) }, reqPackageAccess(perm.AccessModeWrite)) }, reqPackageAccess(perm.AccessModeRead)) + r.Group("/swift", func() { + r.Group("/{scope}/{name}", func() { + r.Group("", func() { + r.Get("", swift.EnumeratePackageVersions) + r.Get(".json", swift.EnumeratePackageVersions) + }, swift.CheckAcceptMediaType(swift.AcceptJSON)) + r.Group("/{version}", func() { + r.Get("/Package.swift", swift.CheckAcceptMediaType(swift.AcceptSwift), swift.DownloadManifest) + r.Put("", reqPackageAccess(perm.AccessModeWrite), swift.CheckAcceptMediaType(swift.AcceptJSON), swift.UploadPackageFile) + r.Get("", func(ctx *context.Context) { + // Can't use normal routes here: https://github.com/go-chi/chi/issues/781 + + version := ctx.Params("version") + if strings.HasSuffix(version, ".zip") { + swift.CheckAcceptMediaType(swift.AcceptZip)(ctx) + if ctx.Written() { + return + } + ctx.SetParams("version", version[:len(version)-4]) + swift.DownloadPackageFile(ctx) + } else { + swift.CheckAcceptMediaType(swift.AcceptJSON)(ctx) + if ctx.Written() { + return + } + if strings.HasSuffix(version, ".json") { + ctx.SetParams("version", version[:len(version)-5]) + } + swift.PackageVersionMetadata(ctx) + } + }) + }) + }) + r.Get("/identifiers", swift.CheckAcceptMediaType(swift.AcceptJSON), swift.LookupPackageIdentifiers) + }, reqPackageAccess(perm.AccessModeRead)) r.Group("/vagrant", func() { r.Group("/authenticate", func() { r.Get("", vagrant.CheckAuthenticate) diff --git a/routers/api/packages/swift/swift.go b/routers/api/packages/swift/swift.go new file mode 100644 index 0000000000..f78f703778 --- /dev/null +++ b/routers/api/packages/swift/swift.go @@ -0,0 +1,464 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package swift + +import ( + "errors" + "fmt" + "io" + "net/http" + "regexp" + "sort" + "strings" + + packages_model "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + packages_module "code.gitea.io/gitea/modules/packages" + swift_module "code.gitea.io/gitea/modules/packages/swift" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/api/packages/helper" + packages_service "code.gitea.io/gitea/services/packages" + + "github.com/hashicorp/go-version" +) + +// https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#35-api-versioning +const ( + AcceptJSON = "application/vnd.swift.registry.v1+json" + AcceptSwift = "application/vnd.swift.registry.v1+swift" + AcceptZip = "application/vnd.swift.registry.v1+zip" +) + +var ( + // https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#361-package-scope + scopePattern = regexp.MustCompile(`\A[a-zA-Z0-9][a-zA-Z0-9-]{0,38}\z`) + // https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#362-package-name + namePattern = regexp.MustCompile(`\A[a-zA-Z0-9][a-zA-Z0-9-_]{0,99}\z`) +) + +type headers struct { + Status int + ContentType string + Digest string + Location string + Link string +} + +// https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#35-api-versioning +func setResponseHeaders(resp http.ResponseWriter, h *headers) { + if h.ContentType != "" { + resp.Header().Set("Content-Type", h.ContentType) + } + if h.Digest != "" { + resp.Header().Set("Digest", "sha256="+h.Digest) + } + if h.Location != "" { + resp.Header().Set("Location", h.Location) + } + if h.Link != "" { + resp.Header().Set("Link", h.Link) + } + resp.Header().Set("Content-Version", "1") + if h.Status != 0 { + resp.WriteHeader(h.Status) + } +} + +// https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#33-error-handling +func apiError(ctx *context.Context, status int, obj interface{}) { + // https://www.rfc-editor.org/rfc/rfc7807 + type Problem struct { + Status int `json:"status"` + Detail string `json:"detail"` + } + + helper.LogAndProcessError(ctx, status, obj, func(message string) { + setResponseHeaders(ctx.Resp, &headers{ + Status: status, + ContentType: "application/problem+json", + }) + if err := json.NewEncoder(ctx.Resp).Encode(Problem{ + Status: status, + Detail: message, + }); err != nil { + log.Error("JSON encode: %v", err) + } + }) +} + +// https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#35-api-versioning +func CheckAcceptMediaType(requiredAcceptHeader string) func(ctx *context.Context) { + return func(ctx *context.Context) { + accept := ctx.Req.Header.Get("Accept") + if accept != "" && accept != requiredAcceptHeader { + apiError(ctx, http.StatusBadRequest, fmt.Sprintf("Unexpected accept header. Should be '%s'.", requiredAcceptHeader)) + } + } +} + +func buildPackageID(scope, name string) string { + return scope + "." + name +} + +type Release struct { + URL string `json:"url"` +} + +type EnumeratePackageVersionsResponse struct { + Releases map[string]Release `json:"releases"` +} + +// https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#41-list-package-releases +func EnumeratePackageVersions(ctx *context.Context) { + packageScope := ctx.Params("scope") + packageName := ctx.Params("name") + + pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeSwift, buildPackageID(packageScope, packageName)) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(pvs) == 0 { + apiError(ctx, http.StatusNotFound, nil) + return + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + sort.Slice(pds, func(i, j int) bool { + return pds[i].SemVer.LessThan(pds[j].SemVer) + }) + + baseURL := fmt.Sprintf("%sapi/packages/%s/swift/%s/%s/", setting.AppURL, ctx.Package.Owner.LowerName, packageScope, packageName) + + releases := make(map[string]Release) + for _, pd := range pds { + version := pd.SemVer.String() + releases[version] = Release{ + URL: baseURL + version, + } + } + + setResponseHeaders(ctx.Resp, &headers{ + Link: fmt.Sprintf(`<%s%s>; rel="latest-version"`, baseURL, pds[len(pds)-1].Version.Version), + }) + + ctx.JSON(http.StatusOK, EnumeratePackageVersionsResponse{ + Releases: releases, + }) +} + +type Resource struct { + Name string `json:"id"` + Type string `json:"type"` + Checksum string `json:"checksum"` +} + +type PackageVersionMetadataResponse struct { + ID string `json:"id"` + Version string `json:"version"` + Resources []Resource `json:"resources"` + Metadata *swift_module.SoftwareSourceCode `json:"metadata"` +} + +// https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#endpoint-2 +func PackageVersionMetadata(ctx *context.Context) { + id := buildPackageID(ctx.Params("scope"), ctx.Params("name")) + + pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeSwift, id, ctx.Params("version")) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + pd, err := packages_model.GetPackageDescriptor(ctx, pv) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + metadata := pd.Metadata.(*swift_module.Metadata) + + setResponseHeaders(ctx.Resp, &headers{}) + + ctx.JSON(http.StatusOK, PackageVersionMetadataResponse{ + ID: id, + Version: pd.Version.Version, + Resources: []Resource{ + { + Name: "source-archive", + Type: "application/zip", + Checksum: pd.Files[0].Blob.HashSHA256, + }, + }, + Metadata: &swift_module.SoftwareSourceCode{ + Context: []string{"http://schema.org/"}, + Type: "SoftwareSourceCode", + Name: pd.PackageProperties.GetByName(swift_module.PropertyName), + Version: pd.Version.Version, + Description: metadata.Description, + Keywords: metadata.Keywords, + CodeRepository: metadata.RepositoryURL, + License: metadata.License, + ProgrammingLanguage: swift_module.ProgrammingLanguage{ + Type: "ComputerLanguage", + Name: "Swift", + URL: "https://swift.org", + }, + Author: swift_module.Person{ + Type: "Person", + GivenName: metadata.Author.GivenName, + MiddleName: metadata.Author.MiddleName, + FamilyName: metadata.Author.FamilyName, + }, + }, + }) +} + +// https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#43-fetch-manifest-for-a-package-release +func DownloadManifest(ctx *context.Context) { + packageScope := ctx.Params("scope") + packageName := ctx.Params("name") + packageVersion := ctx.Params("version") + + pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeSwift, buildPackageID(packageScope, packageName), packageVersion) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + pd, err := packages_model.GetPackageDescriptor(ctx, pv) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + swiftVersion := ctx.FormTrim("swift-version") + if swiftVersion != "" { + v, err := version.NewVersion(swiftVersion) + if err == nil { + swiftVersion = swift_module.TrimmedVersionString(v) + } + } + m, ok := pd.Metadata.(*swift_module.Metadata).Manifests[swiftVersion] + if !ok { + setResponseHeaders(ctx.Resp, &headers{ + Status: http.StatusSeeOther, + Location: fmt.Sprintf("%sapi/packages/%s/swift/%s/%s/%s/Package.swift", setting.AppURL, ctx.Package.Owner.LowerName, packageScope, packageName, packageVersion), + }) + return + } + + setResponseHeaders(ctx.Resp, &headers{}) + + filename := "Package.swift" + if swiftVersion != "" { + filename = fmt.Sprintf("Package@swift-%s.swift", swiftVersion) + } + + ctx.ServeContent(strings.NewReader(m.Content), &context.ServeHeaderOptions{ + ContentType: "text/x-swift", + Filename: filename, + LastModified: pv.CreatedUnix.AsLocalTime(), + }) +} + +// https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#endpoint-6 +func UploadPackageFile(ctx *context.Context) { + packageScope := ctx.Params("scope") + packageName := ctx.Params("name") + + v, err := version.NewVersion(ctx.Params("version")) + + if !scopePattern.MatchString(packageScope) || !namePattern.MatchString(packageName) || err != nil { + apiError(ctx, http.StatusBadRequest, err) + return + } + + packageVersion := v.Core().String() + + file, _, err := ctx.Req.FormFile("source-archive") + if err != nil { + apiError(ctx, http.StatusBadRequest, err) + return + } + defer file.Close() + + buf, err := packages_module.CreateHashedBufferFromReader(file, 32*1024*1024) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer buf.Close() + + var mr io.Reader + metadata := ctx.Req.FormValue("metadata") + if metadata != "" { + mr = strings.NewReader(metadata) + } + + pck, err := swift_module.ParsePackage(buf, buf.Size(), mr) + if err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + apiError(ctx, http.StatusBadRequest, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + if _, err := buf.Seek(0, io.SeekStart); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pv, _, err := packages_service.CreatePackageAndAddFile( + &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeSwift, + Name: buildPackageID(packageScope, packageName), + Version: packageVersion, + }, + SemverCompatible: true, + Creator: ctx.Doer, + Metadata: pck.Metadata, + PackageProperties: map[string]string{ + swift_module.PropertyScope: packageScope, + swift_module.PropertyName: packageName, + }, + }, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: fmt.Sprintf("%s-%s.zip", packageName, packageVersion), + }, + Creator: ctx.Doer, + Data: buf, + IsLead: true, + }, + ) + if err != nil { + switch err { + case packages_model.ErrDuplicatePackageVersion: + apiError(ctx, http.StatusConflict, err) + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + for _, url := range pck.RepositoryURLs { + _, err = packages_model.InsertProperty(ctx, packages_model.PropertyTypeVersion, pv.ID, swift_module.PropertyRepositoryURL, url) + if err != nil { + log.Error("InsertProperty failed: %v", err) + } + } + + setResponseHeaders(ctx.Resp, &headers{}) + + ctx.Status(http.StatusCreated) +} + +// https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#endpoint-4 +func DownloadPackageFile(ctx *context.Context) { + pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeSwift, buildPackageID(ctx.Params("scope"), ctx.Params("name")), ctx.Params("version")) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + pd, err := packages_model.GetPackageDescriptor(ctx, pv) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pf := pd.Files[0].File + + s, _, err := packages_service.GetPackageFileStream(ctx, pf) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer s.Close() + + setResponseHeaders(ctx.Resp, &headers{ + Digest: pd.Files[0].Blob.HashSHA256, + }) + + ctx.ServeContent(s, &context.ServeHeaderOptions{ + Filename: pf.Name, + ContentType: "application/zip", + LastModified: pf.CreatedUnix.AsLocalTime(), + }) +} + +type LookupPackageIdentifiersResponse struct { + Identifiers []string `json:"identifiers"` +} + +// https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#endpoint-5 +func LookupPackageIdentifiers(ctx *context.Context) { + url := ctx.FormTrim("url") + if url == "" { + apiError(ctx, http.StatusBadRequest, nil) + return + } + + pvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Type: packages_model.TypeSwift, + Properties: map[string]string{ + swift_module.PropertyRepositoryURL: url, + }, + IsInternal: util.OptionalBoolFalse, + }) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + if len(pvs) == 0 { + apiError(ctx, http.StatusNotFound, nil) + return + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + identifiers := make([]string, 0, len(pds)) + for _, pd := range pds { + identifiers = append(identifiers, pd.Package.Name) + } + + setResponseHeaders(ctx.Resp, &headers{}) + + ctx.JSON(http.StatusOK, LookupPackageIdentifiersResponse{ + Identifiers: identifiers, + }) +} diff --git a/routers/api/v1/packages/package.go b/routers/api/v1/packages/package.go index ab077090d1..200dc5aaf1 100644 --- a/routers/api/v1/packages/package.go +++ b/routers/api/v1/packages/package.go @@ -40,7 +40,7 @@ func ListPackages(ctx *context.APIContext) { // in: query // description: package type filter // type: string - // enum: [cargo, chef, composer, conan, conda, container, generic, helm, maven, npm, nuget, pub, pypi, rubygems, vagrant] + // enum: [cargo, chef, composer, conan, conda, container, generic, helm, maven, npm, nuget, pub, pypi, rubygems, swift, vagrant] // - name: q // in: query // description: name filter diff --git a/services/forms/package_form.go b/services/forms/package_form.go index b22ed47c77..699d0fe44f 100644 --- a/services/forms/package_form.go +++ b/services/forms/package_form.go @@ -15,7 +15,7 @@ import ( type PackageCleanupRuleForm struct { ID int64 Enabled bool - Type string `binding:"Required;In(cargo,chef,composer,conan,conda,container,generic,helm,maven,npm,nuget,pub,pypi,rubygems,vagrant)"` + Type string `binding:"Required;In(cargo,chef,composer,conan,conda,container,generic,helm,maven,npm,nuget,pub,pypi,rubygems,swift,vagrant)"` KeepCount int `binding:"In(0,1,5,10,25,50,100)"` KeepPattern string `binding:"RegexPattern"` RemoveDays int `binding:"In(0,7,14,30,60,90,180)"` diff --git a/services/packages/packages.go b/services/packages/packages.go index 3abca7337c..dd5c63470b 100644 --- a/services/packages/packages.go +++ b/services/packages/packages.go @@ -361,6 +361,8 @@ func CheckSizeQuotaExceeded(ctx context.Context, doer, owner *user_model.User, p typeSpecificSize = setting.Packages.LimitSizePyPI case packages_model.TypeRubyGems: typeSpecificSize = setting.Packages.LimitSizeRubyGems + case packages_model.TypeSwift: + typeSpecificSize = setting.Packages.LimitSizeSwift case packages_model.TypeVagrant: typeSpecificSize = setting.Packages.LimitSizeVagrant } diff --git a/templates/package/content/swift.tmpl b/templates/package/content/swift.tmpl new file mode 100644 index 0000000000..3ff06483b8 --- /dev/null +++ b/templates/package/content/swift.tmpl @@ -0,0 +1,40 @@ +{{if eq .PackageDescriptor.Package.Type "swift"}} +

{{.locale.Tr "packages.installation"}}

+
+
+
+ +
swift package-registry set 
+
+
+ +
dependencies: [
+	.package(id: "{{.PackageDescriptor.Package.Name}}", from:"{{.PackageDescriptor.Version.Version}}")
+]
+
+
+ +
swift package resolve
+
+
+ +
+
+
+ + {{if .PackageDescriptor.Metadata.Description}} +

{{.locale.Tr "packages.about"}}

+
+ {{if .PackageDescriptor.Metadata.Description}}{{.PackageDescriptor.Metadata.Description}}{{end}} +
+ {{end}} + + {{if .PackageDescriptor.Metadata.Keywords}} +

{{.locale.Tr "packages.keywords"}}

+
+ {{range .PackageDescriptor.Metadata.Keywords}} + {{.}} + {{end}} +
+ {{end}} +{{end}} diff --git a/templates/package/metadata/swift.tmpl b/templates/package/metadata/swift.tmpl new file mode 100644 index 0000000000..8a9ab071fc --- /dev/null +++ b/templates/package/metadata/swift.tmpl @@ -0,0 +1,4 @@ +{{if eq .PackageDescriptor.Package.Type "swift"}} + {{if .PackageDescriptor.Metadata.Author.String}}
{{svg "octicon-person" 16 "mr-3"}} {{.PackageDescriptor.Metadata.Author}}
{{end}} + {{if .PackageDescriptor.Metadata.RepositoryURL}}
{{svg "octicon-link-external" 16 "mr-3"}} {{.locale.Tr "packages.details.project_site"}}
{{end}} +{{end}} diff --git a/templates/package/view.tmpl b/templates/package/view.tmpl index 839d9cf21a..b2a2fb1e5d 100644 --- a/templates/package/view.tmpl +++ b/templates/package/view.tmpl @@ -33,6 +33,7 @@ {{template "package/content/pub" .}} {{template "package/content/pypi" .}} {{template "package/content/rubygems" .}} + {{template "package/content/swift" .}} {{template "package/content/vagrant" .}}
@@ -59,6 +60,7 @@ {{template "package/metadata/pub" .}} {{template "package/metadata/pypi" .}} {{template "package/metadata/rubygems" .}} + {{template "package/metadata/swift" .}} {{template "package/metadata/vagrant" .}}
{{svg "octicon-database" 16 "gt-mr-3"}} {{FileSize .PackageDescriptor.CalculateBlobSize}}
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index b64f6dcd87..9c46b25eaf 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -2120,6 +2120,7 @@ "pub", "pypi", "rubygems", + "swift", "vagrant" ], "type": "string", diff --git a/tests/integration/api_packages_swift_test.go b/tests/integration/api_packages_swift_test.go new file mode 100644 index 0000000000..a3035ea604 --- /dev/null +++ b/tests/integration/api_packages_swift_test.go @@ -0,0 +1,326 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "mime/multipart" + "net/http" + "strings" + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + swift_module "code.gitea.io/gitea/modules/packages/swift" + "code.gitea.io/gitea/modules/setting" + swift_router "code.gitea.io/gitea/routers/api/packages/swift" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestPackageSwift(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + packageScope := "test-scope" + packageName := "test_package" + packageID := packageScope + "." + packageName + packageVersion := "1.0.3" + packageAuthor := "KN4CK3R" + packageDescription := "Gitea Test Package" + packageRepositoryURL := "https://gitea.io/gitea/gitea" + contentManifest1 := "// swift-tools-version:5.7\n//\n// Package.swift" + contentManifest2 := "// swift-tools-version:5.6\n//\n// Package@swift-5.6.swift" + + url := fmt.Sprintf("/api/packages/%s/swift", user.Name) + + t.Run("CheckAcceptMediaType", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + for _, sub := range []string{ + "/scope/package", + "/scope/package.json", + "/scope/package/1.0.0", + "/scope/package/1.0.0.json", + "/scope/package/1.0.0.zip", + "/scope/package/1.0.0/Package.swift", + "/identifiers", + } { + req := NewRequest(t, "GET", url+sub) + req.Header.Add("Accept", "application/unknown") + resp := MakeRequest(t, req, http.StatusBadRequest) + + assert.Equal(t, "1", resp.Header().Get("Content-Version")) + assert.Equal(t, "application/problem+json", resp.Header().Get("Content-Type")) + } + + req := NewRequestWithBody(t, "PUT", url+"/scope/package/1.0.0", strings.NewReader("")) + req = AddBasicAuthHeader(req, user.Name) + req.Header.Add("Accept", "application/unknown") + resp := MakeRequest(t, req, http.StatusBadRequest) + + assert.Equal(t, "1", resp.Header().Get("Content-Version")) + assert.Equal(t, "application/problem+json", resp.Header().Get("Content-Type")) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + uploadPackage := func(t *testing.T, url string, expectedStatus int, sr io.Reader, metadata string) { + var body bytes.Buffer + mpw := multipart.NewWriter(&body) + + part, _ := mpw.CreateFormFile("source-archive", "source-archive.zip") + io.Copy(part, sr) + + if metadata != "" { + mpw.WriteField("metadata", metadata) + } + + mpw.Close() + + req := NewRequestWithBody(t, "PUT", url, &body) + req.Header.Add("Content-Type", mpw.FormDataContentType()) + req.Header.Add("Accept", swift_router.AcceptJSON) + req = AddBasicAuthHeader(req, user.Name) + MakeRequest(t, req, expectedStatus) + } + + createArchive := func(files map[string]string) *bytes.Buffer { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for filename, content := range files { + w, _ := zw.Create(filename) + w.Write([]byte(content)) + } + zw.Close() + return &buf + } + + for _, triple := range []string{"/sc_ope/package/1.0.0", "/scope/pack~age/1.0.0", "/scope/package/1_0.0"} { + req := NewRequestWithBody(t, "PUT", url+triple, bytes.NewReader([]byte{})) + req = AddBasicAuthHeader(req, user.Name) + resp := MakeRequest(t, req, http.StatusBadRequest) + + assert.Equal(t, "1", resp.Header().Get("Content-Version")) + assert.Equal(t, "application/problem+json", resp.Header().Get("Content-Type")) + } + + uploadURL := fmt.Sprintf("%s/%s/%s/%s", url, packageScope, packageName, packageVersion) + + req := NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader([]byte{})) + MakeRequest(t, req, http.StatusUnauthorized) + + uploadPackage( + t, + uploadURL, + http.StatusCreated, + createArchive(map[string]string{ + "Package.swift": contentManifest1, + "Package@swift-5.6.swift": contentManifest2, + }), + `{"name":"`+packageName+`","version":"`+packageVersion+`","description":"`+packageDescription+`","codeRepository":"`+packageRepositoryURL+`","author":{"givenName":"`+packageAuthor+`"},"repositoryURLs":["`+packageRepositoryURL+`"]}`, + ) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeSwift) + assert.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + assert.NoError(t, err) + assert.NotNil(t, pd.SemVer) + assert.Equal(t, packageID, pd.Package.Name) + assert.Equal(t, packageVersion, pd.Version.Version) + assert.IsType(t, &swift_module.Metadata{}, pd.Metadata) + metadata := pd.Metadata.(*swift_module.Metadata) + assert.Equal(t, packageDescription, metadata.Description) + assert.Len(t, metadata.Manifests, 2) + assert.Equal(t, contentManifest1, metadata.Manifests[""].Content) + assert.Equal(t, contentManifest2, metadata.Manifests["5.6"].Content) + assert.Len(t, pd.VersionProperties, 1) + assert.Equal(t, packageRepositoryURL, pd.VersionProperties.GetByName(swift_module.PropertyRepositoryURL)) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + assert.NoError(t, err) + assert.Len(t, pfs, 1) + assert.Equal(t, fmt.Sprintf("%s-%s.zip", packageName, packageVersion), pfs[0].Name) + assert.True(t, pfs[0].IsLead) + + uploadPackage( + t, + uploadURL, + http.StatusConflict, + createArchive(map[string]string{ + "Package.swift": contentManifest1, + }), + "", + ) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s/%s.zip", url, packageScope, packageName, packageVersion)) + req = AddBasicAuthHeader(req, user.Name) + req.Header.Add("Accept", swift_router.AcceptZip) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "1", resp.Header().Get("Content-Version")) + assert.Equal(t, "application/zip", resp.Header().Get("Content-Type")) + + pv, err := packages.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages.TypeSwift, packageID, packageVersion) + assert.NotNil(t, pv) + assert.NoError(t, err) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pv) + assert.NoError(t, err) + assert.Equal(t, "sha256="+pd.Files[0].Blob.HashSHA256, resp.Header().Get("Digest")) + }) + + t.Run("EnumeratePackageVersions", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s", url, packageScope, packageName)) + req = AddBasicAuthHeader(req, user.Name) + req.Header.Add("Accept", swift_router.AcceptJSON) + resp := MakeRequest(t, req, http.StatusOK) + + versionURL := setting.AppURL + url[1:] + fmt.Sprintf("/%s/%s/%s", packageScope, packageName, packageVersion) + + assert.Equal(t, "1", resp.Header().Get("Content-Version")) + assert.Equal(t, fmt.Sprintf(`<%s>; rel="latest-version"`, versionURL), resp.Header().Get("Link")) + + body := resp.Body.String() + + var result *swift_router.EnumeratePackageVersionsResponse + DecodeJSON(t, resp, &result) + + assert.Len(t, result.Releases, 1) + assert.Contains(t, result.Releases, packageVersion) + assert.Equal(t, versionURL, result.Releases[packageVersion].URL) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s.json", url, packageScope, packageName)) + req = AddBasicAuthHeader(req, user.Name) + resp = MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, body, resp.Body.String()) + }) + + t.Run("PackageVersionMetadata", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s/%s", url, packageScope, packageName, packageVersion)) + req = AddBasicAuthHeader(req, user.Name) + req.Header.Add("Accept", swift_router.AcceptJSON) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "1", resp.Header().Get("Content-Version")) + + body := resp.Body.String() + + var result *swift_router.PackageVersionMetadataResponse + DecodeJSON(t, resp, &result) + + pv, err := packages.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages.TypeSwift, packageID, packageVersion) + assert.NotNil(t, pv) + assert.NoError(t, err) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pv) + assert.NoError(t, err) + + assert.Equal(t, packageID, result.ID) + assert.Equal(t, packageVersion, result.Version) + assert.Len(t, result.Resources, 1) + assert.Equal(t, "source-archive", result.Resources[0].Name) + assert.Equal(t, "application/zip", result.Resources[0].Type) + assert.Equal(t, pd.Files[0].Blob.HashSHA256, result.Resources[0].Checksum) + assert.Equal(t, "SoftwareSourceCode", result.Metadata.Type) + assert.Equal(t, packageName, result.Metadata.Name) + assert.Equal(t, packageVersion, result.Metadata.Version) + assert.Equal(t, packageDescription, result.Metadata.Description) + assert.Equal(t, "Swift", result.Metadata.ProgrammingLanguage.Name) + assert.Equal(t, packageAuthor, result.Metadata.Author.GivenName) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s/%s.json", url, packageScope, packageName, packageVersion)) + req = AddBasicAuthHeader(req, user.Name) + resp = MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, body, resp.Body.String()) + }) + + t.Run("DownloadManifest", func(t *testing.T) { + manifestURL := fmt.Sprintf("%s/%s/%s/%s/Package.swift", url, packageScope, packageName, packageVersion) + + t.Run("Default", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", manifestURL) + req = AddBasicAuthHeader(req, user.Name) + req.Header.Add("Accept", swift_router.AcceptSwift) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "1", resp.Header().Get("Content-Version")) + assert.Equal(t, "text/x-swift", resp.Header().Get("Content-Type")) + assert.Equal(t, contentManifest1, resp.Body.String()) + }) + + t.Run("DifferentVersion", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", manifestURL+"?swift-version=5.6") + req = AddBasicAuthHeader(req, user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "1", resp.Header().Get("Content-Version")) + assert.Equal(t, "text/x-swift", resp.Header().Get("Content-Type")) + assert.Equal(t, contentManifest2, resp.Body.String()) + + req = NewRequest(t, "GET", manifestURL+"?swift-version=5.6.0") + req = AddBasicAuthHeader(req, user.Name) + MakeRequest(t, req, http.StatusOK) + }) + + t.Run("Redirect", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", manifestURL+"?swift-version=1.0") + req = AddBasicAuthHeader(req, user.Name) + resp := MakeRequest(t, req, http.StatusSeeOther) + + assert.Equal(t, "1", resp.Header().Get("Content-Version")) + assert.Equal(t, setting.AppURL+url[1:]+fmt.Sprintf("/%s/%s/%s/Package.swift", packageScope, packageName, packageVersion), resp.Header().Get("Location")) + }) + }) + + t.Run("LookupPackageIdentifiers", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", url+"/identifiers") + req.Header.Add("Accept", swift_router.AcceptJSON) + resp := MakeRequest(t, req, http.StatusBadRequest) + + assert.Equal(t, "1", resp.Header().Get("Content-Version")) + assert.Equal(t, "application/problem+json", resp.Header().Get("Content-Type")) + + req = NewRequest(t, "GET", url+"/identifiers?url=https://unknown.host/") + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "GET", url+"/identifiers?url="+packageRepositoryURL) + req.Header.Add("Accept", swift_router.AcceptJSON) + resp = MakeRequest(t, req, http.StatusOK) + + var result *swift_router.LookupPackageIdentifiersResponse + DecodeJSON(t, resp, &result) + + assert.Len(t, result.Identifiers, 1) + assert.Equal(t, packageID, result.Identifiers[0]) + }) +} diff --git a/web_src/svg/gitea-swift.svg b/web_src/svg/gitea-swift.svg new file mode 100644 index 0000000000..8af43d32e4 --- /dev/null +++ b/web_src/svg/gitea-swift.svg @@ -0,0 +1,5 @@ + + + + +