diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml
index a0ee30cab9..eb9ec82ee1 100644
--- a/docs/api/swagger.yaml
+++ b/docs/api/swagger.yaml
@@ -4873,9 +4873,11 @@ paths:
in: query
name: min_id
type: string
- - default: 20
- description: Number of accounts to return.
+ - default: 40
+ description: 'Number of accounts to return. If set to 0 explicitly, all accounts in the list will be returned, and pagination headers will not be used. This is a workaround for Mastodon API peculiarities: https://docs.joinmastodon.org/methods/lists/#query-parameters.'
in: query
+ maximum: 80
+ minimum: 0
name: limit
type: integer
produces:
diff --git a/internal/api/client/lists/listaccounts.go b/internal/api/client/lists/listaccounts.go
index da902384f3..6feffb1e81 100644
--- a/internal/api/client/lists/listaccounts.go
+++ b/internal/api/client/lists/listaccounts.go
@@ -79,8 +79,13 @@ import (
// -
// name: limit
// type: integer
-// description: Number of accounts to return.
-// default: 20
+// description: >-
+// Number of accounts to return.
+// If set to 0 explicitly, all accounts in the list will be returned, and pagination headers will not be used.
+// This is a workaround for Mastodon API peculiarities: https://docs.joinmastodon.org/methods/lists/#query-parameters.
+// default: 40
+// minimum: 0
+// maximum: 80
// in: query
// required: false
//
@@ -129,14 +134,31 @@ func (m *Module) ListAccountsGETHandler(c *gin.Context) {
return
}
- limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20, 40, 1)
+ limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 40, 80, 0)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
+ var (
+ ctx = c.Request.Context()
+ )
+
+ if limit == 0 {
+ // Return all accounts in the list without pagination.
+ accounts, errWithCode := m.processor.List().GetAllListAccounts(ctx, authed.Account, targetListID)
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ c.JSON(http.StatusOK, accounts)
+ return
+ }
+
+ // Return subset of accounts in the list with pagination.
resp, errWithCode := m.processor.List().GetListAccounts(
- c.Request.Context(),
+ ctx,
authed.Account,
targetListID,
c.Query(MaxIDKey),
diff --git a/internal/api/client/lists/listaccounts_test.go b/internal/api/client/lists/listaccounts_test.go
new file mode 100644
index 0000000000..64e9ef7683
--- /dev/null
+++ b/internal/api/client/lists/listaccounts_test.go
@@ -0,0 +1,246 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package lists_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/lists"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type ListAccountsTestSuite struct {
+ ListsStandardTestSuite
+}
+
+func (suite *ListAccountsTestSuite) getListAccounts(
+ expectedHTTPStatus int,
+ expectedBody string,
+ listID string,
+ maxID string,
+ sinceID string,
+ minID string,
+ limit *int,
+) (
+ []*apimodel.Account,
+ string, // Link header
+ error,
+) {
+
+ var (
+ recorder = httptest.NewRecorder()
+ ctx, _ = testrig.CreateGinTestContext(recorder, nil)
+ )
+
+ // Prepare test context.
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"]))
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+
+ // Inject path parameters.
+ ctx.AddParam("id", listID)
+
+ // Inject query parameters.
+ requestPath := config.GetProtocol() + "://" + config.GetHost() + "/api/" + lists.BasePath + "/" + listID + "/accounts"
+
+ if limit != nil {
+ requestPath += "?limit=" + strconv.Itoa(*limit)
+ } else {
+ requestPath += "?limit=40"
+ }
+ if maxID != "" {
+ requestPath += "&" + apiutil.MaxIDKey + "=" + maxID
+ }
+ if sinceID != "" {
+ requestPath += "&" + apiutil.SinceIDKey + "=" + sinceID
+ }
+ if minID != "" {
+ requestPath += "&" + apiutil.MinIDKey + "=" + minID
+ }
+
+ // Prepare test context request.
+ request := httptest.NewRequest(http.MethodGet, requestPath, nil)
+ request.Header.Set("accept", "application/json")
+ ctx.Request = request
+
+ // trigger the handler
+ suite.listsModule.ListAccountsGETHandler(ctx)
+
+ // read the response
+ result := recorder.Result()
+ defer result.Body.Close()
+
+ b, err := ioutil.ReadAll(result.Body)
+ if err != nil {
+ return nil, "", err
+ }
+
+ errs := gtserror.MultiError{}
+
+ // check code + body
+ if resultCode := recorder.Code; expectedHTTPStatus != resultCode {
+ errs = append(errs, fmt.Sprintf("expected %d got %d", expectedHTTPStatus, resultCode))
+ }
+
+ // if we got an expected body, return early
+ if expectedBody != "" {
+ if string(b) != expectedBody {
+ errs = append(errs, fmt.Sprintf("expected %s got %s", expectedBody, string(b)))
+ }
+ return nil, "", errs.Combine()
+ }
+
+ resp := []*apimodel.Account{}
+ if err := json.Unmarshal(b, &resp); err != nil {
+ return nil, "", err
+ }
+
+ return resp, result.Header.Get("Link"), nil
+}
+
+func (suite *ListAccountsTestSuite) TestGetListAccountsPaginatedDefaultLimit() {
+ var (
+ expectedHTTPStatus = 200
+ expectedBody = ""
+ listID = suite.testLists["local_account_1_list_1"].ID
+ maxID = ""
+ minID = ""
+ sinceID = ""
+ limit *int = nil
+ )
+
+ accounts, link, err := suite.getListAccounts(
+ expectedHTTPStatus,
+ expectedBody,
+ listID,
+ maxID,
+ sinceID,
+ minID,
+ limit,
+ )
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ suite.Len(accounts, 2)
+ suite.Equal(
+ `; rel="next", `+
+ `; rel="prev"`,
+ link,
+ )
+}
+
+func (suite *ListAccountsTestSuite) TestGetListAccountsPaginatedNextPage() {
+ var (
+ expectedHTTPStatus = 200
+ expectedBody = ""
+ listID = suite.testLists["local_account_1_list_1"].ID
+ maxID = ""
+ minID = ""
+ sinceID = ""
+ limit *int = func() *int { l := 1; return &l }() // Set to 1.
+ )
+
+ // First response, ask for 1 account.
+ accounts, link, err := suite.getListAccounts(
+ expectedHTTPStatus,
+ expectedBody,
+ listID,
+ maxID,
+ sinceID,
+ minID,
+ limit,
+ )
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ suite.Len(accounts, 1)
+ suite.Equal(
+ `; rel="next", `+
+ `; rel="prev"`,
+ link,
+ )
+
+ // Next response, ask for next 1 account.
+ maxID = "01H0G8FFM1AGQDRNGBGGX8CYJQ"
+ accounts, link, err = suite.getListAccounts(
+ expectedHTTPStatus,
+ expectedBody,
+ listID,
+ maxID,
+ sinceID,
+ minID,
+ limit,
+ )
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ suite.Len(accounts, 1)
+ suite.Equal(
+ `; rel="next", `+
+ `; rel="prev"`,
+ link,
+ )
+}
+
+func (suite *ListAccountsTestSuite) TestGetListAccountsUnpaginated() {
+ var (
+ expectedHTTPStatus = 200
+ expectedBody = ""
+ listID = suite.testLists["local_account_1_list_1"].ID
+ maxID = ""
+ minID = ""
+ sinceID = ""
+ limit *int = func() *int { l := 0; return &l }() // Set to 0 explicitly.
+ )
+
+ accounts, link, err := suite.getListAccounts(
+ expectedHTTPStatus,
+ expectedBody,
+ listID,
+ maxID,
+ sinceID,
+ minID,
+ limit,
+ )
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ suite.Len(accounts, 2)
+ suite.Empty(link)
+}
+
+func TestListAccountsTestSuite(t *testing.T) {
+ suite.Run(t, new(ListAccountsTestSuite))
+}
diff --git a/internal/api/client/lists/lists_test.go b/internal/api/client/lists/lists_test.go
new file mode 100644
index 0000000000..ebaf6998e2
--- /dev/null
+++ b/internal/api/client/lists/lists_test.go
@@ -0,0 +1,108 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package lists_test
+
+import (
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/lists"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/email"
+ "github.com/superseriousbusiness/gotosocial/internal/federation"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/internal/processing"
+ "github.com/superseriousbusiness/gotosocial/internal/state"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
+ "github.com/superseriousbusiness/gotosocial/internal/visibility"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type ListsStandardTestSuite struct {
+ // standard suite interfaces
+ suite.Suite
+ db db.DB
+ storage *storage.Driver
+ mediaManager *media.Manager
+ federator federation.Federator
+ processor *processing.Processor
+ emailSender email.Sender
+ state state.State
+
+ // standard suite models
+ testTokens map[string]*gtsmodel.Token
+ testClients map[string]*gtsmodel.Client
+ testApplications map[string]*gtsmodel.Application
+ testUsers map[string]*gtsmodel.User
+ testAccounts map[string]*gtsmodel.Account
+ testAttachments map[string]*gtsmodel.MediaAttachment
+ testStatuses map[string]*gtsmodel.Status
+ testEmojis map[string]*gtsmodel.Emoji
+ testEmojiCategories map[string]*gtsmodel.EmojiCategory
+ testLists map[string]*gtsmodel.List
+
+ // module being tested
+ listsModule *lists.Module
+}
+
+func (suite *ListsStandardTestSuite) SetupSuite() {
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testAttachments = testrig.NewTestAttachments()
+ suite.testStatuses = testrig.NewTestStatuses()
+ suite.testEmojis = testrig.NewTestEmojis()
+ suite.testEmojiCategories = testrig.NewTestEmojiCategories()
+ suite.testLists = testrig.NewTestLists()
+}
+
+func (suite *ListsStandardTestSuite) SetupTest() {
+ suite.state.Caches.Init()
+ suite.state.Caches.Start()
+ testrig.StartWorkers(&suite.state)
+
+ testrig.InitTestConfig()
+ testrig.InitTestLog()
+
+ suite.db = testrig.NewTestDB(&suite.state)
+ suite.state.DB = suite.db
+ suite.storage = testrig.NewInMemoryStorage()
+ suite.state.Storage = suite.storage
+
+ testrig.StartTimelines(
+ &suite.state,
+ visibility.NewFilter(&suite.state),
+ testrig.NewTestTypeConverter(suite.db),
+ )
+
+ suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
+ suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
+ suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil)
+ suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.listsModule = lists.New(suite.processor)
+
+ testrig.StandardDBSetup(suite.db, nil)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+}
+
+func (suite *ListsStandardTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+ testrig.StopWorkers(&suite.state)
+}
diff --git a/internal/api/util/parsequery.go b/internal/api/util/parsequery.go
index 92105ef824..6628709106 100644
--- a/internal/api/util/parsequery.go
+++ b/internal/api/util/parsequery.go
@@ -27,11 +27,12 @@ import (
const (
/* Common keys */
- IDKey = "id"
- LimitKey = "limit"
- LocalKey = "local"
- MaxIDKey = "max_id"
- MinIDKey = "min_id"
+ IDKey = "id"
+ LimitKey = "limit"
+ LocalKey = "local"
+ MaxIDKey = "max_id"
+ SinceIDKey = "since_id"
+ MinIDKey = "min_id"
/* Search keys */
@@ -76,10 +77,8 @@ func ParseLimit(value string, defaultValue int, max, min int) (int, gtserror.Wit
i, err := parseInt(value, defaultValue, max, min, LimitKey)
if err != nil {
return 0, err
- } else if i == 0 {
- // treat 0 as an empty query
- return defaultValue, nil
}
+
return i, nil
}
diff --git a/internal/processing/list/get.go b/internal/processing/list/get.go
index 0fc14f934c..1a03898ed5 100644
--- a/internal/processing/list/get.go
+++ b/internal/processing/list/get.go
@@ -75,6 +75,46 @@ func (p *Processor) GetAll(ctx context.Context, account *gtsmodel.Account) ([]*a
return apiLists, nil
}
+// GetAllListAccounts returns all accounts that are in the given list,
+// owned by the given account. There's no pagination for this endpoint.
+//
+// See https://docs.joinmastodon.org/methods/lists/#query-parameters:
+//
+// Limit: Integer. Maximum number of results. Defaults to 40 accounts.
+// Max 80 accounts. Set to 0 in order to get all accounts without pagination.
+func (p *Processor) GetAllListAccounts(
+ ctx context.Context,
+ account *gtsmodel.Account,
+ listID string,
+) ([]*apimodel.Account, gtserror.WithCode) {
+ // Ensure list exists + is owned by requesting account.
+ _, errWithCode := p.getList(
+ // Use barebones ctx; no embedded
+ // structs necessary for this call.
+ gtscontext.SetBarebones(ctx),
+ account.ID,
+ listID,
+ )
+ if errWithCode != nil {
+ return nil, errWithCode
+ }
+
+ // Get all entries for this list.
+ listEntries, err := p.state.DB.GetListEntries(ctx, listID, "", "", "", 0)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ err = gtserror.Newf("error getting list entries: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ // Extract accounts from list entries + add them to response.
+ accounts := make([]*apimodel.Account, 0, len(listEntries))
+ p.accountsFromListEntries(ctx, listEntries, func(acc *apimodel.Account) {
+ accounts = append(accounts, acc)
+ })
+
+ return accounts, nil
+}
+
// GetListAccounts returns accounts that are in the given list, owned by the given account.
// The additional parameters can be used for paging.
func (p *Processor) GetListAccounts(
@@ -121,6 +161,25 @@ func (p *Processor) GetListAccounts(
prevMinIDValue = listEntries[0].ID
)
+ // Extract accounts from list entries + add them to response.
+ p.accountsFromListEntries(ctx, listEntries, func(acc *apimodel.Account) {
+ items = append(items, acc)
+ })
+
+ return util.PackagePageableResponse(util.PageableResponseParams{
+ Items: items,
+ Path: "/api/v1/lists/" + listID + "/accounts",
+ NextMaxIDValue: nextMaxIDValue,
+ PrevMinIDValue: prevMinIDValue,
+ Limit: limit,
+ })
+}
+
+func (p *Processor) accountsFromListEntries(
+ ctx context.Context,
+ listEntries []*gtsmodel.ListEntry,
+ appendAcc func(*apimodel.Account),
+) {
// For each list entry, we want the account it points to.
// To get this, we need to first get the follow that the
// list entry pertains to, then extract the target account
@@ -144,14 +203,6 @@ func (p *Processor) GetListAccounts(
continue
}
- items = append(items, apiAccount)
+ appendAcc(apiAccount)
}
-
- return util.PackagePageableResponse(util.PageableResponseParams{
- Items: items,
- Path: "/api/v1/lists/" + listID + "/accounts",
- NextMaxIDValue: nextMaxIDValue,
- PrevMinIDValue: prevMinIDValue,
- Limit: limit,
- })
}