Skip to content

Add API endpoint to request contents of multiple files simultaniously #34139

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 41 commits into from
Apr 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
52de00c
Add multiple files API endpoint
denyskon Apr 6, 2025
deacb2a
Add swagger
denyskon Apr 7, 2025
f95113a
Merge branch 'main' into feat/multiple-files-api
denyskon Apr 7, 2025
b3165c6
Remove contents prefix
denyskon Apr 7, 2025
12316ea
Merge branch 'feat/multiple-files-api' of ssh://github.com/denyskon/g…
denyskon Apr 7, 2025
5de2936
Add integration test for files api
denyskon Apr 10, 2025
0d3d84a
Merge branch 'main' into feat/multiple-files-api
denyskon Apr 10, 2025
adfefad
Format files
denyskon Apr 10, 2025
a085105
Merge branch 'feat/multiple-files-api' of ssh://github.com/denyskon/g…
denyskon Apr 10, 2025
992dc2b
Merge remote-tracking branch 'upstream/main' into feat/multiple-files…
denyskon Apr 13, 2025
4085174
Remove useless function and test
denyskon Apr 13, 2025
6b08536
Apply pagination before retreiving files
denyskon Apr 18, 2025
57dc3d1
Add response size limit
denyskon Apr 18, 2025
4b231a3
Fix linter
denyskon Apr 18, 2025
3881828
Add error if file could not be retrieved
denyskon Apr 18, 2025
090d25d
Merge branch 'main' into feat/multiple-files-api
denyskon Apr 18, 2025
8eaff5a
Fix linter
denyskon Apr 18, 2025
4976d3e
Merge branch 'feat/multiple-files-api' of ssh://github.com/denyskon/g…
denyskon Apr 18, 2025
579a0a0
Revert "Add error if file could not be retrieved"
denyskon Apr 18, 2025
648a2a4
Fix for fileContents = nil
denyskon Apr 18, 2025
5c5577a
Fix api settings endpoint
denyskon Apr 19, 2025
18811c7
Switch pagination method and add test
denyskon Apr 20, 2025
668e401
Merge branch 'main' into feat/multiple-files-api
denyskon Apr 20, 2025
91f4193
Fix compliance
denyskon Apr 20, 2025
5707644
Merge branch 'feat/multiple-files-api' of ssh://github.com/denyskon/g…
denyskon Apr 20, 2025
6911a9a
temp
wxiaoguang Apr 21, 2025
114d73f
temp
wxiaoguang Apr 21, 2025
d173d4e
temp
wxiaoguang Apr 21, 2025
f6d949b
fix tests
wxiaoguang Apr 21, 2025
aa2cc66
fix lint & test
wxiaoguang Apr 21, 2025
388058d
Adapt tests for 4/3 size increase
denyskon Apr 21, 2025
9071187
fix lint & test
wxiaoguang Apr 21, 2025
8a2f9f3
fine tune
wxiaoguang Apr 21, 2025
8a5b4f8
fine tune tests
wxiaoguang Apr 21, 2025
ff4a105
fix
wxiaoguang Apr 21, 2025
d867f33
add GET method support
wxiaoguang Apr 21, 2025
d236e75
fix lint
wxiaoguang Apr 21, 2025
bc14c5d
Merge branch 'main' into feat/multiple-files-api
wxiaoguang Apr 21, 2025
c23f4b8
update swagger doc
wxiaoguang Apr 21, 2025
57ed5e0
fine tune comments
wxiaoguang Apr 21, 2025
b6c7465
fix swagger doc, "master" is no longer "usually"
wxiaoguang Apr 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions custom/conf/app.example.ini
Original file line number Diff line number Diff line change
Expand Up @@ -2439,6 +2439,8 @@ LEVEL = Info
;DEFAULT_GIT_TREES_PER_PAGE = 1000
;; Default max size of a blob returned by the blobs API (default is 10MiB)
;DEFAULT_MAX_BLOB_SIZE = 10485760
;; Default max combined size of all blobs returned by the files API (default is 100MiB)
;DEFAULT_MAX_RESPONSE_SIZE = 104857600

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
Expand Down
14 changes: 0 additions & 14 deletions modules/git/repo_object.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,17 +85,3 @@ func (repo *Repository) hashObject(reader io.Reader, save bool) (string, error)
}
return strings.TrimSpace(stdout.String()), nil
}

// GetRefType gets the type of the ref based on the string
func (repo *Repository) GetRefType(ref string) ObjectType {
if repo.IsTagExist(ref) {
return ObjectTag
} else if repo.IsBranchExist(ref) {
return ObjectBranch
} else if repo.IsCommitExist(ref) {
return ObjectCommit
} else if _, err := repo.GetBlob(ref); err == nil {
return ObjectBlob
}
return ObjectType("invalid")
}
2 changes: 2 additions & 0 deletions modules/setting/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ var API = struct {
DefaultPagingNum int
DefaultGitTreesPerPage int
DefaultMaxBlobSize int64
DefaultMaxResponseSize int64
}{
EnableSwagger: true,
SwaggerURL: "",
MaxResponseItems: 50,
DefaultPagingNum: 30,
DefaultGitTreesPerPage: 1000,
DefaultMaxBlobSize: 10485760,
DefaultMaxResponseSize: 104857600,
}

func loadAPIFrom(rootCfg ConfigProvider) {
Expand Down
10 changes: 5 additions & 5 deletions modules/structs/git_blob.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ package structs

// GitBlobResponse represents a git blob
type GitBlobResponse struct {
Content string `json:"content"`
Encoding string `json:"encoding"`
URL string `json:"url"`
SHA string `json:"sha"`
Size int64 `json:"size"`
Content *string `json:"content"`
Encoding *string `json:"encoding"`
URL string `json:"url"`
SHA string `json:"sha"`
Size int64 `json:"size"`
}
5 changes: 5 additions & 0 deletions modules/structs/repo_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,8 @@ type FileDeleteResponse struct {
Commit *FileCommitResponse `json:"commit"`
Verification *PayloadCommitVerification `json:"verification"`
}

// GetFilesOptions options for retrieving metadate and content of multiple files
type GetFilesOptions struct {
Files []string `json:"files" binding:"Required"`
}
1 change: 1 addition & 0 deletions modules/structs/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type GeneralAPISettings struct {
DefaultPagingNum int `json:"default_paging_num"`
DefaultGitTreesPerPage int `json:"default_git_trees_per_page"`
DefaultMaxBlobSize int64 `json:"default_max_blob_size"`
DefaultMaxResponseSize int64 `json:"default_max_response_size"`
}

// GeneralAttachmentSettings contains global Attachment settings exposed by API
Expand Down
7 changes: 5 additions & 2 deletions routers/api/v1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -1389,14 +1389,17 @@ func Routes() *web.Router {
m.Post("/diffpatch", reqRepoWriter(unit.TypeCode), reqToken(), bind(api.ApplyDiffPatchFileOptions{}), mustNotBeArchived, repo.ApplyDiffPatch)
m.Group("/contents", func() {
m.Get("", repo.GetContentsList)
m.Post("", reqToken(), bind(api.ChangeFilesOptions{}), reqRepoBranchWriter, mustNotBeArchived, repo.ChangeFiles)
m.Get("/*", repo.GetContents)
m.Post("", reqToken(), bind(api.ChangeFilesOptions{}), reqRepoBranchWriter, mustNotBeArchived, repo.ChangeFiles)
m.Group("/*", func() {
m.Post("", bind(api.CreateFileOptions{}), reqRepoBranchWriter, mustNotBeArchived, repo.CreateFile)
m.Put("", bind(api.UpdateFileOptions{}), reqRepoBranchWriter, mustNotBeArchived, repo.UpdateFile)
m.Delete("", bind(api.DeleteFileOptions{}), reqRepoBranchWriter, mustNotBeArchived, repo.DeleteFile)
}, reqToken())
}, reqRepoReader(unit.TypeCode))
}, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo())
m.Combo("/file-contents", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo()).
Get(repo.GetFileContentsGet).
Post(bind(api.GetFilesOptions{}), repo.GetFileContentsPost) // POST method requires "write" permission, so we also support "GET" method above
m.Get("/signing-key.gpg", misc.SigningKey)
m.Group("/topics", func() {
m.Combo("").Get(repo.ListTopics).
Expand Down
138 changes: 120 additions & 18 deletions routers/api/v1/repo/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,18 @@ import (

git_model "code.gitea.io/gitea/models/git"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/httpcache"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/lfs"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/api/v1/utils"
"code.gitea.io/gitea/routers/common"
"code.gitea.io/gitea/services/context"
pull_service "code.gitea.io/gitea/services/pull"
Expand Down Expand Up @@ -375,7 +377,7 @@ func GetEditorconfig(ctx *context.APIContext) {
// required: true
// - name: ref
// in: query
// description: "The name of the commit/branch/tag. Default the repository’s default branch (usually master)"
// description: "The name of the commit/branch/tag. Default to the repository’s default branch."
// type: string
// required: false
// responses:
Expand Down Expand Up @@ -410,11 +412,6 @@ func canWriteFiles(ctx *context.APIContext, branch string) bool {
!ctx.Repo.Repository.IsArchived
}

// canReadFiles returns true if repository is readable and user has proper access level.
func canReadFiles(r *context.Repository) bool {
return r.Permission.CanRead(unit.TypeCode)
}

func base64Reader(s string) (io.ReadSeeker, error) {
b, err := base64.StdEncoding.DecodeString(s)
if err != nil {
Expand Down Expand Up @@ -894,6 +891,17 @@ func DeleteFile(ctx *context.APIContext) {
}
}

func resolveRefCommit(ctx *context.APIContext, ref string, minCommitIDLen ...int) *utils.RefCommit {
ref = util.IfZero(ref, ctx.Repo.Repository.DefaultBranch)
refCommit, err := utils.ResolveRefCommit(ctx, ctx.Repo.Repository, ref, minCommitIDLen...)
if errors.Is(err, util.ErrNotExist) {
ctx.APIErrorNotFound(err)
} else if err != nil {
ctx.APIErrorInternal(err)
}
return refCommit
}

// GetContents Get the metadata and contents (if a file) of an entry in a repository, or a list of entries if a dir
func GetContents(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/contents/{filepath} repository repoGetContents
Expand All @@ -919,7 +927,7 @@ func GetContents(ctx *context.APIContext) {
// required: true
// - name: ref
// in: query
// description: "The name of the commit/branch/tag. Default the repository’s default branch (usually master)"
// description: "The name of the commit/branch/tag. Default to the repository’s default branch."
// type: string
// required: false
// responses:
Expand All @@ -928,18 +936,13 @@ func GetContents(ctx *context.APIContext) {
// "404":
// "$ref": "#/responses/notFound"

if !canReadFiles(ctx.Repo) {
ctx.APIErrorInternal(repo_model.ErrUserDoesNotHaveAccessToRepo{
UserID: ctx.Doer.ID,
RepoName: ctx.Repo.Repository.LowerName,
})
treePath := ctx.PathParam("*")
refCommit := resolveRefCommit(ctx, ctx.FormTrim("ref"))
if ctx.Written() {
return
}

treePath := ctx.PathParam("*")
ref := ctx.FormTrim("ref")

if fileList, err := files_service.GetContentsOrList(ctx, ctx.Repo.Repository, treePath, ref); err != nil {
if fileList, err := files_service.GetContentsOrList(ctx, ctx.Repo.Repository, refCommit, treePath); err != nil {
if git.IsErrNotExist(err) {
ctx.APIErrorNotFound("GetContentsOrList", err)
return
Expand Down Expand Up @@ -970,7 +973,7 @@ func GetContentsList(ctx *context.APIContext) {
// required: true
// - name: ref
// in: query
// description: "The name of the commit/branch/tag. Default the repository’s default branch (usually master)"
// description: "The name of the commit/branch/tag. Default to the repository’s default branch."
// type: string
// required: false
// responses:
Expand All @@ -982,3 +985,102 @@ func GetContentsList(ctx *context.APIContext) {
// same as GetContents(), this function is here because swagger fails if path is empty in GetContents() interface
GetContents(ctx)
}

func GetFileContentsGet(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/file-contents repository repoGetFileContents
// ---
// summary: Get the metadata and contents of requested files
// description: See the POST method. This GET method supports to use JSON encoded request body in query parameter.
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// - name: ref
// in: query
// description: "The name of the commit/branch/tag. Default to the repository’s default branch."
// type: string
// required: false
// - name: body
// in: query
// description: "The JSON encoded body (see the POST request): {\"files\": [\"filename1\", \"filename2\"]}"
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/ContentsListResponse"
// "404":
// "$ref": "#/responses/notFound"

// POST method requires "write" permission, so we also support this "GET" method
handleGetFileContents(ctx)
}

func GetFileContentsPost(ctx *context.APIContext) {
// swagger:operation POST /repos/{owner}/{repo}/file-contents repository repoGetFileContentsPost
// ---
// summary: Get the metadata and contents of requested files
// description: Uses automatic pagination based on default page size and
// max response size and returns the maximum allowed number of files.
// Files which could not be retrieved are null. Files which are too large
// are being returned with `encoding == null`, `content == null` and `size > 0`,
// they can be requested separately by using the `download_url`.
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// - name: ref
// in: query
// description: "The name of the commit/branch/tag. Default to the repository’s default branch."
// type: string
// required: false
// - name: body
// in: body
// required: true
// schema:
// "$ref": "#/definitions/GetFilesOptions"
// responses:
// "200":
// "$ref": "#/responses/ContentsListResponse"
// "404":
// "$ref": "#/responses/notFound"

// This is actually a "read" request, but we need to accept a "files" list, then POST method seems easy to use.
// But the permission system requires that the caller must have "write" permission to use POST method.
// At the moment there is no other way to get around the permission check, so there is a "GET" workaround method above.
handleGetFileContents(ctx)
}

func handleGetFileContents(ctx *context.APIContext) {
opts, ok := web.GetForm(ctx).(*api.GetFilesOptions)
if !ok {
err := json.Unmarshal(util.UnsafeStringToBytes(ctx.FormString("body")), &opts)
if err != nil {
ctx.APIError(http.StatusBadRequest, "invalid body parameter")
return
}
}
refCommit := resolveRefCommit(ctx, ctx.FormTrim("ref"))
if ctx.Written() {
return
}
filesResponse := files_service.GetContentsListFromTreePaths(ctx, ctx.Repo.Repository, refCommit, opts.Files)
ctx.JSON(http.StatusOK, util.SliceNilAsEmpty(filesResponse))
}
22 changes: 8 additions & 14 deletions routers/api/v1/repo/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,33 +177,27 @@ func GetCommitStatusesByRef(ctx *context.APIContext) {
// "404":
// "$ref": "#/responses/notFound"

filter := utils.ResolveRefOrSha(ctx, ctx.PathParam("ref"))
refCommit := resolveRefCommit(ctx, ctx.PathParam("ref"), 7)
if ctx.Written() {
return
}

getCommitStatuses(ctx, filter) // By default filter is maybe the raw SHA
getCommitStatuses(ctx, refCommit.CommitID)
}

func getCommitStatuses(ctx *context.APIContext, sha string) {
if len(sha) == 0 {
ctx.APIError(http.StatusBadRequest, nil)
return
}
sha = utils.MustConvertToSHA1(ctx.Base, ctx.Repo, sha)
func getCommitStatuses(ctx *context.APIContext, commitID string) {
repo := ctx.Repo.Repository

listOptions := utils.GetListOptions(ctx)

statuses, maxResults, err := db.FindAndCount[git_model.CommitStatus](ctx, &git_model.CommitStatusOptions{
ListOptions: listOptions,
RepoID: repo.ID,
SHA: sha,
SHA: commitID,
SortType: ctx.FormTrim("sort"),
State: ctx.FormTrim("state"),
})
if err != nil {
ctx.APIErrorInternal(fmt.Errorf("GetCommitStatuses[%s, %s, %d]: %w", repo.FullName(), sha, ctx.FormInt("page"), err))
ctx.APIErrorInternal(fmt.Errorf("GetCommitStatuses[%s, %s, %d]: %w", repo.FullName(), commitID, ctx.FormInt("page"), err))
return
}

Expand Down Expand Up @@ -257,16 +251,16 @@ func GetCombinedCommitStatusByRef(ctx *context.APIContext) {
// "404":
// "$ref": "#/responses/notFound"

sha := utils.ResolveRefOrSha(ctx, ctx.PathParam("ref"))
refCommit := resolveRefCommit(ctx, ctx.PathParam("ref"), 7)
if ctx.Written() {
return
}

repo := ctx.Repo.Repository

statuses, count, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, utils.GetListOptions(ctx))
statuses, count, err := git_model.GetLatestCommitStatus(ctx, repo.ID, refCommit.Commit.ID.String(), utils.GetListOptions(ctx))
if err != nil {
ctx.APIErrorInternal(fmt.Errorf("GetLatestCommitStatus[%s, %s]: %w", repo.FullName(), sha, err))
ctx.APIErrorInternal(fmt.Errorf("GetLatestCommitStatus[%s, %s]: %w", repo.FullName(), refCommit.CommitID, err))
return
}

Expand Down
1 change: 1 addition & 0 deletions routers/api/v1/settings/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ func GetGeneralAPISettings(ctx *context.APIContext) {
DefaultPagingNum: setting.API.DefaultPagingNum,
DefaultGitTreesPerPage: setting.API.DefaultGitTreesPerPage,
DefaultMaxBlobSize: setting.API.DefaultMaxBlobSize,
DefaultMaxResponseSize: setting.API.DefaultMaxResponseSize,
})
}

Expand Down
3 changes: 3 additions & 0 deletions routers/api/v1/swagger/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ type swaggerParameterBodies struct {
// in:body
EditAttachmentOptions api.EditAttachmentOptions

// in:body
GetFilesOptions api.GetFilesOptions

// in:body
ChangeFilesOptions api.ChangeFilesOptions

Expand Down
Loading